diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index ae98dde3..2bbbcf80 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono" -version = "0.1.2" +version = "0.1.3" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 7e2aefbc..90b9ef37 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -65,7 +65,7 @@ async fn catch_all(Path(params): Path>, request: Request // TODO: remove unwrap - let payload = tuono_lib::Payload::new(&req, Box::new("")) + let payload = tuono_lib::Payload::new(&req, &"") .client_payload() .unwrap(); diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index 9e3069a8..5772e304 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib" -version = "0.1.2" +version = "0.1.3" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" @@ -24,4 +24,5 @@ serde = { version = "1.0.202", features = ["derive"] } erased-serde = "0.4.5" serde_json = "1.0" -tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.2"} +tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.3"} + diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index 21c82c0d..4b5fdb79 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -6,6 +6,6 @@ pub mod ssr; pub use payload::Payload; pub use request::Request; -pub use response::Response; +pub use response::{Props, Response}; pub use ssr_rs::Ssr; pub use tuono_lib_macros::handler; diff --git a/crates/tuono_lib/src/payload.rs b/crates/tuono_lib/src/payload.rs index cb0e34ea..f0f45dcc 100644 --- a/crates/tuono_lib/src/payload.rs +++ b/crates/tuono_lib/src/payload.rs @@ -8,11 +8,11 @@ use crate::Request; /// This is the data shared to the client pub struct Payload<'a> { router: Location<'a>, - props: Box, + props: &'a dyn Serialize, } impl<'a> Payload<'a> { - pub fn new(req: &Request<'a>, props: Box) -> Payload<'a> { + pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> { Payload { router: req.location(), props, diff --git a/crates/tuono_lib/src/response.rs b/crates/tuono_lib/src/response.rs index 88d0bf18..f217b55f 100644 --- a/crates/tuono_lib/src/response.rs +++ b/crates/tuono_lib/src/response.rs @@ -1,6 +1,56 @@ +use crate::Request; +use crate::{ssr::Js, Payload}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse}; +use axum::Json; use erased_serde::Serialize; +pub struct Props { + data: Box, + http_code: StatusCode, +} + pub enum Response { Redirect(String), - Props(Box), + Props(Props), +} + +impl Props { + pub fn new(data: impl Serialize + 'static) -> Self { + Props { + data: Box::new(data), + http_code: StatusCode::OK, + } + } + + pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self { + Props { + data: Box::new(data), + http_code, + } + } +} + +impl Response { + pub fn render_to_string(&self, req: Request) -> impl IntoResponse { + match self { + Self::Props(Props { data, http_code }) => { + let payload = Payload::new(&req, data).client_payload().unwrap(); + + match Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) { + Ok(html) => (*http_code, Html(html)), + Err(_) => (*http_code, Html("500 Internal server error".to_string())), + } + } + // TODO: Handle here other enum arms + _ => todo!(), + } + } + + pub fn json(&self) -> impl IntoResponse { + match self { + Self::Props(Props { data, http_code }) => (*http_code, Json(data)).into_response(), + _ => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json("{}")).into_response(), + } + } } diff --git a/crates/tuono_lib_macros/Cargo.toml b/crates/tuono_lib_macros/Cargo.toml index 2a2ced29..647a1b3c 100644 --- a/crates/tuono_lib_macros/Cargo.toml +++ b/crates/tuono_lib_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib_macros" -version = "0.1.2" +version = "0.1.3" edition = "2021" description = "The react/rust fullstack framework" homepage = "https://github.com/Valerioageno/tuono" diff --git a/crates/tuono_lib_macros/src/handler.rs b/crates/tuono_lib_macros/src/handler.rs index 3ce8b70b..0c8f2bae 100644 --- a/crates/tuono_lib_macros/src/handler.rs +++ b/crates/tuono_lib_macros/src/handler.rs @@ -8,7 +8,7 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream { let fn_name = item.clone().sig.ident; quote! { - use axum::response::{Html, IntoResponse}; + use axum::response::IntoResponse; use std::collections::HashMap; use axum::extract::{State, Path}; use reqwest::Client; @@ -16,51 +16,29 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream { #item pub async fn route( - Path(params): Path>, - State(client): State, + Path(params): Path>, + State(client): State, request: axum::extract::Request - ) -> Html { + ) -> impl IntoResponse { let pathname = &request.uri(); let headers = &request.headers(); let req = tuono_lib::Request::new(pathname, headers, params); - let local_response = #fn_name(req.clone(), client).await; - - let res = match local_response { - tuono_lib::Response::Props(val) => { - - // TODO: remove unwrap - let payload = tuono_lib::Payload::new(&req, val).client_payload().unwrap(); - - tuono_lib::ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) - }, - /// TODO: handle here redirection and rewrite - _ => Ok("500 Internal server error".to_string()) - }; - - match res { - Ok(html) => Html(html), - _ => Html("500 internal server error".to_string()) - } + #fn_name(req.clone(), client).await.render_to_string(req) } pub async fn api( - Path(params): Path>, - State(client): State, + Path(params): Path>, + State(client): State, request: axum::extract::Request - ) -> axum::response::Response { - let pathname = &request.uri(); + ) -> impl IntoResponse{ + let pathname = &request.uri(); let headers = &request.headers(); let req = tuono_lib::Request::new(pathname, headers, params); - let local_response = #fn_name(req.clone(), client).await; - - let res = match local_response{ - tuono_lib::Response::Props(val) => return axum::Json(val).into_response(), - _ => return axum::Json("").into_response() - }; + #fn_name(req.clone(), client).await.json() } } .into() diff --git a/docs/tutorial.md b/docs/tutorial.md index be7e26a2..ca6246bf 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -6,6 +6,20 @@ The first part is about the project setup and the base knowledge needed to work > If you prefer to see directly the final outcome check out the [tutorial source](https://github.com/Valerioageno/tuono/tree/main/examples/tutorial). +## Table of Content + +* [Tuono tutorial](#tuono-tutorial) + * [Installation](#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) + ## Installation The tuono CLI is hosted on [crates.io](https://crates.io/crates/tuono); to download and install it just run on a terminal: @@ -93,7 +107,7 @@ Clear the `index.rs` file and paste: ```rust // src/routes/index.rs use serde::{Deserialize, Serialize}; -use tuono_lib::{Request, Response}; +use tuono_lib::{Props, Request, Response}; const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151"; @@ -110,15 +124,13 @@ struct Pokemon { #[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::().await.unwrap(); - Response::Props(Box::new(data)) + Response::Props(Props::new(data)) } - Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })), + Err(_err) => Response::Props(Props::new("{}")), }; - } ``` @@ -302,8 +314,9 @@ These two will handle every requests that points to `http://localhost:3000/pokem Let’s first work on the server side file. Paste into the new `[pokemon].rs` file the following code: ```rust +// src/routes/pokemons/[pokemon].rs use serde::{Deserialize, Serialize}; -use tuono_lib::{Request, Response}; +use tuono_lib::{Props, Request, Response}; const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon"; @@ -317,22 +330,16 @@ struct Pokemon { #[tuono_lib::handler] async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response { - // The param `pokemon` is defined in the route filename [pokemon].rs + // 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) => { let data = res.json::().await.unwrap(); - Response::Props(Box::new(data)) + Response::Props(Props::new(data)) } - Err(_err) => Response::Props(Box::new(Pokemon { - name: "Nope".to_string(), - id: 0, - weight: 0, - height: 0, - })), + Err(_err) => Response::Props(Props::new("{}")) }; - } ``` @@ -438,6 +445,92 @@ export default function PokemonView({ } ``` +## 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::().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, +} + +#[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::().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. + ## Conclusion That’s it! You just created a multi thread full stack application with rust and react. diff --git a/examples/tuono/src/routes/index.rs b/examples/tuono/src/routes/index.rs index 8feca98d..e3c693c8 100644 --- a/examples/tuono/src/routes/index.rs +++ b/examples/tuono/src/routes/index.rs @@ -1,5 +1,5 @@ use serde::Serialize; -use tuono_lib::{Request, Response}; +use tuono_lib::{Props, Request, Response}; #[derive(Serialize)] struct MyResponse<'a> { @@ -8,7 +8,7 @@ struct MyResponse<'a> { #[tuono_lib::handler] async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response { - Response::Props(Box::new(MyResponse { + Response::Props(Props::new(MyResponse { subtitle: "The react / rust fullstack framework", })) } diff --git a/examples/tutorial/src/routes/index.rs b/examples/tutorial/src/routes/index.rs index 25649768..76833288 100644 --- a/examples/tutorial/src/routes/index.rs +++ b/examples/tutorial/src/routes/index.rs @@ -1,6 +1,7 @@ // src/routes/index.rs +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use tuono_lib::{Request, Response}; +use tuono_lib::{Props, Request, Response}; const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151"; @@ -20,8 +21,11 @@ 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::().await.unwrap(); - Response::Props(Box::new(data)) + Response::Props(Props::new(data)) } - Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })), + Err(_err) => Response::Props(Props::new_with_status( + "{}", // Return empty JSON + StatusCode::INTERNAL_SERVER_ERROR, + )), }; } diff --git a/examples/tutorial/src/routes/pokemons/[pokemon].rs b/examples/tutorial/src/routes/pokemons/[pokemon].rs index 1251e91e..756cbf7a 100644 --- a/examples/tutorial/src/routes/pokemons/[pokemon].rs +++ b/examples/tutorial/src/routes/pokemons/[pokemon].rs @@ -1,5 +1,7 @@ +// src/routes/pokemons/[pokemon].rs +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use tuono_lib::{Request, Response}; +use tuono_lib::{Props, Request, Response}; const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon"; @@ -18,14 +20,15 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response { 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::().await.unwrap(); - Response::Props(Box::new(data)) + Response::Props(Props::new(data)) } - Err(_err) => Response::Props(Box::new(Pokemon { - name: "Nope".to_string(), - id: 0, - weight: 0, - height: 0, - })), + Err(_err) => Response::Props(Props::new_with_status( + "{}", + StatusCode::INTERNAL_SERVER_ERROR, + )), }; } diff --git a/examples/tutorial/src/routes/pokemons/[pokemon].tsx b/examples/tutorial/src/routes/pokemons/[pokemon].tsx index 7cf77395..b480a6fe 100644 --- a/examples/tutorial/src/routes/pokemons/[pokemon].tsx +++ b/examples/tutorial/src/routes/pokemons/[pokemon].tsx @@ -1,6 +1,15 @@ -import { TuonoProps } from 'tuono' +import type { TuonoProps } from 'tuono' import PokemonView from '../../components/PokemonView' -export default function Pokemon({ data }: TuonoProps): JSX.Element { +interface Pokemon { + name: string + id: string + weight: number + height: number +} + +export default function PokemonPage({ + data, +}: TuonoProps): JSX.Element { return } diff --git a/package.json b/package.json index f82489b8..b1e24abd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.1.2", + "version": "0.1.3", "description": "", "main": "src/index.js", "packageManager": "pnpm@9.1.1", diff --git a/packages/tuono/package.json b/packages/tuono/package.json index eec6082c..9547ee41 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.1.2", + "version": "0.1.3", "description": "The react/rust fullstack framework", "scripts": { "dev": "vite build --watch",