2024-06-06 20:50:58 +02:00
# Tuono tutorial
This tutorial is meant for giving you a sneak peek of the framework and is intended to evolve along the development - be sure to have installed the latest version.
The first part is about the project setup and the base knowledge needed to work with tuono. The actual tutorial starts at [Tutorial introduction ](#tutorial-introduction ).
2024-06-15 20:59:07 +02:00
> If you have already installed the tuono CLI you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
2024-06-08 12:50:36 +02:00
2024-06-15 14:24:41 +02:00
## Table of Content
2024-06-15 20:59:07 +02:00
* [CLI Installation ](#cli-installation )
* [Project scaffold ](#project-scaffold )
* [Start the dev environment ](#start-the-dev-environment )
* [The “/” route ](#the--route )
* [Tutorial introduction ](#tutorial-introduction )
* [Fetch all the pokemons ](#fetch-all-the-pokemons )
* [Create a stand-alone component ](#create-a-stand-alone-component )
* [Create the /pokemons/[pokemon] route](#create -the-pokemonspokemon-route)
* [Error handling ](#error-handling )
* [Conclusion ](#conclusion )
2024-06-15 14:24:41 +02:00
2024-06-15 20:59:07 +02:00
## CLI Installation
2024-06-06 20:50:58 +02:00
The tuono CLI is hosted on [crates.io ](https://crates.io/crates/tuono ); to download and install it just run on a terminal:
``` bash
$ cargo install tuono
```
To check that is correctly installed run:
``` bash
$ tuono --version
```
2024-06-15 20:59:07 +02:00
Run `tuono -h` to see all the available commands.
2024-06-06 20:50:58 +02:00
## Project scaffold
2024-06-15 20:59:07 +02:00
To setup a new fresh project you just need to run the following command:
2024-06-06 20:50:58 +02:00
``` bash
$ tuono new tuono-tutorial
```
Get into the project folder and install the dependencies with:
``` bash
$ pnpm install
```
Open it with your favourite code editor.
The project will have the following structure:
2024-06-12 13:42:02 +02:00
```
2024-06-15 14:36:09 +02:00
├── package.json
├── public
├── src
│ ├── routes
│ └── styles
├── Cargo.toml
├── README.md
└── tsconfig.json
2024-06-06 20:50:58 +02:00
```
**public/ ** : put here all the files you want to be public
**src/routes/ ** : All the files in this folder are considered routes. All the routes are server side rendered out of the box. To add server side capabilities just create a rust file with the same name as the route (i.e. `about.tsx` → `about.rs` ).
**src/styles/ ** : In this folder there is the `global.css` file that stores all the global styles. For the rest of the project you can use CSS modules (⚠️ CSS modules on routes are forbidden).
## Start the dev environment
To start the development environment you just need to run the following command within the project folder:
``` bash
$ tuono dev
```
The first time might take a little bit because it will install all the rust’ s dependencies. All the other execution will be pretty quick!
> 💡 The `tuono dev` development script is currently under strong optimization improvements. In case you face any error delete the cache `.tuono` folder and run it again!
Then open [`http://localhost:3000/` ](http://localhost:3000/ ) on the browser.
## The “/” route
All the `index.tsx` files represent the folder root page (i.e. `src/routes/posts/index.tsx` is [`http://localhost:3000/posts` ](http://localhost:3000/posts ) as well as `src/routes/posts.tsx` ).
The file `index.rs` represents the server side capabilities for the index route. On this file you can:
- Passing server side props
- Redirect/Rewrite to a different route (Available soon)
- Changing http status code (Available soon)
## Tutorial introduction
Now that we have some knowledge about the project structure let’ s start the real tutorial.
The goal is to use the [PokeAPI ](https://pokeapi.co/docs/v2 ) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately.
## Fetch all the pokemons
To start let’ s fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file.
Clear the `index.rs` file and paste:
``` rust
// src/routes/index.rs
use serde ::{ Deserialize , Serialize } ;
2024-06-15 14:17:12 +02:00
use tuono_lib ::{ Props , Request , Response } ;
2024-06-06 20:50:58 +02:00
const ALL_POKEMON : & str = " https://pokeapi.co/api/v2/pokemon?limit=151 " ;
#[ derive(Debug, Serialize, Deserialize) ]
struct Pokemons {
results : Vec < Pokemon > ,
}
#[ derive(Debug, Serialize, Deserialize) ]
struct Pokemon {
name : String ,
url : String ,
}
#[ tuono_lib::handler ]
async fn get_all_pokemons ( _req : Request < '_ > , fetch : reqwest ::Client ) -> Response {
return match fetch . get ( ALL_POKEMON ) . send ( ) . await {
Ok ( res ) = > {
let data = res . json ::< Pokemons > ( ) . await . unwrap ( ) ;
2024-06-15 14:17:12 +02:00
Response ::Props ( Props ::new ( data ) )
2024-06-06 20:50:58 +02:00
}
2024-06-15 14:17:12 +02:00
Err ( _err ) = > Response ::Props ( Props ::new ( " {} " ) ) ,
2024-06-06 20:50:58 +02:00
} ;
}
```
Now the pokemons are correctly fetched and hydrated on the client side so we can actually use them. Clear the `index.tsx` file and paste:
``` tsx
// src/routes/index.tsx
import type { TuonoProps } from "tuono" ;
interface Pokemon {
name : string
}
interface IndexProps {
results : Pokemon [ ]
}
export default function IndexPage ( {
data ,
} : TuonoProps < IndexProps > ) : JSX . Element {
if ( ! data ? . results ) {
return < > < / > ;
}
return (
< >
< header className = "header" >
< a href = "https://crates.io/crates/tuono" target = "_blank" >
Crates
< / a >
< a href = "https://www.npmjs.com/package/tuono" target = "_blank" >
Npm
< / a >
< / header >
< div className = "title-wrap" >
< h1 className = "title" >
TU < span > O < / span > NO
< / h1 >
< div className = "logo" >
< img src = "rust.svg" className = "rust" / >
< img src = "react.svg" className = "react" / >
< / div >
< / div >
< ul style = { { flexWrap : "wrap" , display : "flex" , gap : 10 } } >
{ data . results . map ( ( pokemon ) = > {
return pokemon . name ;
} ) }
< / ul >
< / >
) ;
}
```
Refresh now the browser! A bit ugly but all the pokemons are finally printed on screen!
## Create a stand-alone component
Let’ s then create the button needed for displaying the list of pokemons.
Create the following file `src/components/PokemonLink.tsx` and fill the content with:
``` tsx
// src/components/PokemonLink.tsx
import { Link } from "tuono" ;
interface Pokemon {
name : string
}
export default function PokemonLink ( {
pokemon ,
id ,
} : {
pokemon : Pokemon ;
id : number ;
} ) : JSX . Element {
return (
< Link href = { ` /pokemons/ ${ pokemon . name } ` } >
{ pokemon . name }
< img
src = { ` https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/ ${ id } .png ` }
/ >
< / Link >
) ;
}
```
Now that the link is done let’ s import it in the `index.tsx` file
``` diff
// src/routes/index.tsx
++ import PokemonLink from '../components/PokemonLink'
// ...
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
-- {pokemons.map((pokemon) => {
-- return pokemon.name;
++ {pokemons.map((pokemon, i) => {
++ return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />;
})}
</ul>
// ...
```
Now the links work. Clicking on any of them we get redirected to the 404 page because we haven’ t yet implemented the `pokemons/[pokemon]` page.
As previously said CSS modules are enabled out of the box so let’ s make those links a little bit nicer.
Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.module.css` and copy the following content into it:
``` css
/* src/components/PokemonLink.module.css */
. link {
width : 100 % ;
max-width : 216 px ;
position : relative ;
background : white ;
margin-bottom : 10 px ;
border : solid #f0f0f0 1 px ;
text-decoration : none ;
color : black ;
padding : 5 px 5 px 5 px 15 px ;
border-radius : 10 px ;
display : flex ;
justify-content : space-between ;
transition : 0.2 s ;
align-items : center ;
}
. link : hover {
box-shadow : rgba ( 100 , 100 , 111 , 0.2 ) 0 px 7 px 29 px 0 px ;
}
. link img {
width : 70 px ;
background : white ;
border-radius : 50 % ;
}
```
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `pnpm i -D sass` and run again `tuono dev`
Then import the styles into the `PokemonLink` component as following:
``` diff
// src/components/PokemonLink.tsx
import { Link } from "tuono";
import type { Pokemon } from "./../types/pokemon";
++ import styles from './PokemonLink.module.css'
export default function PokemonLink({
pokemon,
id,
}: {
pokemon: Pokemon;
id: number;
}): JSX.Element {
return (
-- <Link href={`/pokemons/${pokemon.name}`}>
++ <Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
{pokemon.name}
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
/>
</Link>
);
}
```
## Create the `/pokemons/[pokemon]` route
The homepage is ready. We have the full list of pokemons and they are all links. Great!
Now we want to make those links actually pointing to a real page. Let’ s create the dynamic route.
Create the folder `routes/pokemons` and then create the two files `[pokemon].tsx` and `[pokemon].rs` .
These two will handle every requests that points to `http://localhost:3000/pokemons/bulbasaur..mew` .
Let’ s first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
``` rust
2024-06-15 14:17:12 +02:00
// src/routes/pokemons/[pokemon].rs
2024-06-06 20:50:58 +02:00
use serde ::{ Deserialize , Serialize } ;
2024-06-15 14:17:12 +02:00
use tuono_lib ::{ Props , Request , Response } ;
2024-06-06 20:50:58 +02:00
const POKEMON_API : & str = " https://pokeapi.co/api/v2/pokemon " ;
#[ derive(Debug, Serialize, Deserialize) ]
struct Pokemon {
name : String ,
id : u16 ,
weight : u16 ,
height : u16 ,
}
#[ tuono_lib::handler ]
async fn get_pokemon ( req : Request < '_ > , fetch : reqwest ::Client ) -> Response {
2024-06-15 14:17:12 +02:00
// The param `pokemon` is defined in the route filename [pokemon].rs
2024-06-06 20:50:58 +02:00
let pokemon = req . params . get ( " pokemon " ) . unwrap ( ) ;
2024-06-15 14:17:12 +02:00
2024-06-06 20:50:58 +02:00
return match fetch . get ( format! ( " {POKEMON_API} / {pokemon} " ) ) . send ( ) . await {
Ok ( res ) = > {
let data = res . json ::< Pokemon > ( ) . await . unwrap ( ) ;
2024-06-15 14:17:12 +02:00
Response ::Props ( Props ::new ( data ) )
2024-06-06 20:50:58 +02:00
}
2024-06-15 14:17:12 +02:00
Err ( _err ) = > Response ::Props ( Props ::new ( " {} " ) )
2024-06-06 20:50:58 +02:00
} ;
}
```
Then let’ s work on the frontend. Paste into the `[pokemon].tsx` file the following code:
``` tsx
import { TuonoProps } from "tuono" ;
import PokemonView from "../../components/PokemonView" ;
export default function Pokemon ( { data } : TuonoProps ) : JSX . Element {
return < PokemonView pokemon = { data } / > ;
}
```
The browser should complain that the component `PokemonView` does not exist. Let’ s create it then!
``` tsx
// components/PokemonView.tsx
import { Link } from "tuono" ;
import styles from "./PokemonView.module.css" ;
interface Pokemon {
name : string
id : string
weight : number
height : number
}
export default function PokemonView ( {
pokemon ,
} : {
pokemon? : Pokemon ;
} ) : JSX . Element {
return (
< div >
< Link className = { styles [ "back-btn" ] } href = "/" >
Back
< / Link >
{ pokemon ? . name && (
< div className = { styles . pokemon } >
< div >
< h1 className = { styles . name } >
{ pokemon . name }
< / h1 >
< dl className = { styles . spec } >
< dt className = { styles . label } > Weight : < / dt >
< dd > { pokemon . weight } lbs < / dd >
< / dl >
< dl className = { styles . spec } >
< dt className = { styles . label } > Height : < / dt >
< dd > { pokemon . height } ft < / dd >
< / dl >
< / div >
< img
src = { ` https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/ ${ pokemon . id } .png ` }
/ >
< / div >
) }
< / div >
) ;
}
```
``` css
/* components/PokemonView.module.css */
. back-btn {
background-color : white ;
border-radius : 10 px ;
padding : 7 px 15 px ;
color : black ;
text-decoration : none ;
border : solid #f0f0f0 1 px ;
font-size : 20 px ;
}
. back-btn : hover {
box-shadow : rgba ( 100 , 100 , 111 , 0.2 ) 0 px 7 px 29 px 0 px ;
}
. pokemon {
display : flex ;
justify-content : space-between ;
margin-top : 20 px ;
}
. name {
font-size : 50 px ;
font-weight : 700 ;
}
. pokemon img {
width : 400 px ;
}
. spec {
display : flex ;
font-size : 18 px ;
margin-top : 10 px ;
}
. label {
font-weight : 700 ;
}
```
2024-06-15 14:17:12 +02:00
## Error handling
With the current setup all the routes always return a `200 Success` http status no matter the response type.
In order to return a more meaningful status code to the browser the `Props` struct can be initialized with also the
`Props::new_with_status()` method.
Let's see how it works!
``` diff
// src/routes/pokemons/[pokemon].rs
++ use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use tuono_lib::{Props, Request, Response};
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
id: u16,
weight: u16,
height: u16,
}
#[tuono_lib::handler]
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
// The param `pokemon` is defined in the route filename [pokemon].rs
let pokemon = req.params.get("pokemon").unwrap();
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
Ok(res) => {
++ if res.status() == StatusCode::NOT_FOUND {
++ return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
++ }
let data = res.json::<Pokemon>().await.unwrap();
Response::Props(Props::new(data))
}
-- Err(_err) => Response::Props(Props::new(
++ Err(_err) => Response::Props(Props::new_with_status(
++ "{}",
++ StatusCode::INTERNAL_SERVER_ERROR,
)),
};
}
```
``` diff
// src/routes/index.rs
++ use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use tuono_lib::{Props, Request, Response};
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemons {
results: Vec<Pokemon>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
url: String,
}
#[tuono_lib::handler]
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
return match fetch.get(ALL_POKEMON).send().await {
Ok(res) => {
let data = res.json::<Pokemons>().await.unwrap();
Response::Props(Props::new(data))
}
-- Err(_err) => Response::Props(Props::new(
++ Err(_err) => Response::Props(Props::new_with_status(
++ "{}", // Return empty JSON
++ StatusCode::INTERNAL_SERVER_ERROR,
)),
};
}
```
If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon` ) you will
correctly receive a 404 status code in the console.
2024-06-06 20:50:58 +02:00
## Conclusion
That’ s it! You just created a multi thread full stack application with rust and react.
The project is still under heavy development and the script for the production build (`tuono build` ) is not ready yet but
I hope you got the taste of what is like working with rust and react in the same stack.