Merge pull request #313 from Kijewski/pr-salvo

Add salvo example by translating the axum example
This commit is contained in:
René Kijewski 2025-01-13 20:35:46 +01:00 committed by GitHub
commit 32b976216c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 596 additions and 7 deletions

View File

@ -26,7 +26,7 @@ jobs:
- run: |
set -eu
for PKG in \
examples/actix-web-app examples/axum-app examples/poem-app examples/rocket-app examples/warp-app fuzzing \
examples/actix-web-app examples/axum-app examples/poem-app examples/rocket-app examples/salvo-app examples/warp-app fuzzing \
rinja rinja_derive rinja_derive_standalone rinja_parser \
testing testing-alloc testing-no-std
do
@ -115,7 +115,7 @@ jobs:
- run: |
set -eu
for PKG in \
examples/actix-web-app examples/axum-app examples/poem-app examples/rocket-app examples/warp-app fuzzing \
examples/actix-web-app examples/axum-app examples/poem-app examples/rocket-app examples/salvo-app examples/warp-app fuzzing \
rinja rinja_derive rinja_derive_standalone rinja_parser \
testing testing-alloc testing-no-std
do
@ -158,7 +158,7 @@ jobs:
strategy:
matrix:
package: [
examples/actix-web-app, examples/axum-app, examples/poem-app, examples/rocket-app, examples/warp-app, fuzzing,
examples/actix-web-app, examples/axum-app, examples/poem-app, examples/rocket-app, examples/salvo-app, examples/warp-app, fuzzing,
rinja, rinja_derive, rinja_derive_standalone, rinja_parser,
testing, testing-alloc, testing-no-std,
]

View File

@ -196,7 +196,6 @@ impl ResponseError for AppError {
}
}
/// This is your error handler
impl IntoResponse for AppError {
fn into_response(self) -> Response {
#[derive(Debug, Template)]
@ -278,6 +277,66 @@ impl<'r> Responder<'r, 'static> for AppError {
}
```
## Salvo
[![our salvo example web-app](
https://img.shields.io/badge/salvo-example-informational?style=flat-square&logo=git&logoColor=white&color=%23228b22
)](
https://github.com/rinja-rs/rinja/tree/master/examples/salvo-app "our salvo example web-app"
)
[![crates.io: salvo](
https://img.shields.io/crates/v/salvo?label=salvo&style=flat-square&logo=rust&logoColor=white&color=informational
)](
https://crates.io/crates/salvo "crates.io: salvo"
)
To convert the `String` to an HTML response, you can use
[`Text::Html(_)`](https://docs.rs/salvo/0.76.0/salvo/prelude/enum.Text.html#variant.Html).
```rust
use salvo::writing::Text;
use salvo::{Scribe, handler};
#[handler]
async fn handler() -> Result<impl Scribe, AppError> {
Ok(Text::Html(template.render()?))
}
```
To implement your own error type, you can use this boilerplate code:
```rust
use rinja::Template;
use salvo::http::StatusCode;
use salvo::writing::Text;
use salvo::{Response, Scribe};
#[derive(Debug, displaydoc::Display, thiserror::Error)]
enum AppError {
/// could not render template
Render(#[from] rinja::Error),
}
impl Scribe for AppError {
fn render(self, res: &mut Response) {
#[derive(Debug, Template)]
#[template(path = "error.html")]
struct Tmpl { … }
res.status_code(match &self {
AppError::Render(_) => StatusCode::INTERNAL_SERVER_ERROR,
});
let tmpl = Tmpl { … };
if let Ok(body) = tmpl.render() {
Text::Html(body).render(res);
} else {
Text::Plain("Something went wrong").render(res);
}
}
}
```
## Warp
[![our warp example web-app](

View File

@ -54,7 +54,7 @@ enum AppError {
impl ResponseError for AppError {
fn status_code(&self) -> StatusCode {
match &self {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}

View File

@ -61,7 +61,7 @@ enum AppError {
impl ResponseError for AppError {
fn status(&self) -> StatusCode {
match &self {
match self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Render(_) => StatusCode::INTERNAL_SERVER_ERROR,
}

View File

@ -1,4 +1,4 @@
# rinja + warp example web app
# rinja + rocket example web app
This is a simple web application that uses rinja as template engine, and
[rocket](https://crates.io/crates/rocket) as web framework.

View File

@ -0,0 +1 @@
../../.rustfmt.toml

View File

@ -0,0 +1,31 @@
[package]
name = "salvo-app"
version = "0.3.5"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
# This is an example application that uses both rinja as template engine,
# and salvo as your web-framework.
[dependencies]
rinja = { version = "0.3.5", path = "../../rinja" }
salvo = { version = "0.76.0", default-features = false, features = ["http1", "logging", "server"] }
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] }
# serde and strum are used to parse (deserialize) and generate (serialize) information
# between web requests, e.g. to share the selected display language.
serde = { version = "1.0.217", features = ["derive"] }
strum = { version = "0.26.3", features = ["derive"] }
# These depenendies are simply used for a better user experience, having access logs in the
# console, and error messages if anything goes wrong, e.g. if the port is already in use.
displaydoc = "0.2.5"
pretty-error-debug = "0.3.1"
thiserror = "2.0.11"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
# In a real application you would not need this section. It is only used in here, so that this
# example can have a more lenient MSRV (minimum supported rust version) than rinja as a whole.
[workspace]
members = ["."]

View File

@ -0,0 +1 @@
../../LICENSE-APACHE

View File

@ -0,0 +1 @@
../../LICENSE-MIT

View File

@ -0,0 +1,17 @@
# rinja + salvo example web app
This is a simple web application that uses rinja as template engine, and
[salvo](https://crates.io/crates/salvo) as web framework.
It lets the user of the web page select a display language, and asks for their name.
The example shows the interaction between both projects, and serves as an example to use
basic rinja features such as base templates to a unified layout skeleton for your page,
and less boilerplate in your template code.
To run the example execute `cargo run` in this folder.
Once the project is running, open <http://127.0.0.1:8080/> in your browser.
To gracefully shut does the server, type ctrl+C in your terminal.
The files of the project contain comments for you to read.
The recommended reading order is "templates/_layout.html", "templates/index.html",
"Cargo.toml", "src/main.rs". Also please have a look at our [book](https://rinja.readthedocs.io/),
which explains rinja's features in greater detail.

View File

@ -0,0 +1 @@
../../_typos.toml

View File

@ -0,0 +1 @@
../../deny.toml

View File

@ -0,0 +1,199 @@
use std::net::{IpAddr, Ipv4Addr};
use rinja::Template;
use salvo::catcher::Catcher;
use salvo::conn::TcpListener;
use salvo::http::StatusCode;
use salvo::logging::Logger;
use salvo::macros::Extractible;
use salvo::writing::{Redirect, Text};
use salvo::{Listener, Request, Response, Router, Scribe, Server, Service, handler};
use serde::Deserialize;
use tracing::Level;
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.init();
let router = Router::new()
.push(Router::new().get(start_handler))
.push(Router::with_path("{lang}/index.html").get(index_handler))
.push(Router::with_path("{lang}/greet-me.html").get(greeting_handler));
let server = Service::new(router)
.catcher(Catcher::default().hoop(not_found_handler))
.hoop(Logger::new());
// In a real application you would most likely read the configuration from a config file.
let acceptor = TcpListener::new((IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
.try_bind()
.await
.map_err(Error::Bind)?;
Server::new(acceptor)
.try_serve(server)
.await
.map_err(Error::Run)
}
#[derive(displaydoc::Display, thiserror::Error, pretty_error_debug::Debug)]
enum Error {
/// could not bind socket
Bind(#[source] salvo::Error),
/// could not run server
Run(#[source] std::io::Error),
}
/// Thanks to this type, your user can select the display language of your page.
///
/// The same type is used by salvo as part of the URL, and in rinja to select what content to show,
/// and also as an HTML attribute in `<html lang=`. To make it possible to use the same type for
/// three different use cases, we use a few derive macros:
///
/// * `Default` to have a default/fallback language.
/// * `Debug` is not strictly needed, but it might aid debugging.
/// * `Clone` + `Copy` so that we can pass the language by value.
/// * `PartialEq` so that we can use the type in comparisons with `==` or `!=`.
/// * `serde::Deserialize` so that salvo can parse the type in incoming URLs.
/// * `strum::Display` so that rinja can write the value in templates.
#[derive(Default, Debug, Clone, Copy, PartialEq, Deserialize, strum::Display)]
#[allow(non_camel_case_types)]
enum Lang {
#[default]
en,
de,
fr,
}
/// This enum contains any error that could occur while handling an incoming request.
///
/// In a real application you would most likely have multiple error sources, e.g. database errors,
#[derive(Debug, displaydoc::Display, thiserror::Error)]
enum AppError {
/// not found
NotFound,
/// could not extract information from request
Extract(#[from] salvo::http::ParseError),
/// could not render template
Render(#[from] rinja::Error),
}
/// This is your error handler
impl Scribe for AppError {
fn render(self, res: &mut Response) {
// It uses a rinja template to display its content.
// The member `lang` is used by "_layout.html" which "error.html" extends. Even though it
// is always the fallback language English in here, "_layout.html" expects to be able to
// access this field, so you have to provide it.
#[derive(Debug, Template)]
#[template(path = "error.html")]
struct Tmpl {
lang: Lang,
err: AppError,
}
res.status_code(match &self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Extract(_) => StatusCode::UNPROCESSABLE_ENTITY,
AppError::Render(_) => StatusCode::INTERNAL_SERVER_ERROR,
});
let tmpl = Tmpl {
lang: Lang::default(),
err: self,
};
if let Ok(body) = tmpl.render() {
Text::Html(body).render(res);
} else {
Text::Plain("Something went wrong").render(res);
}
}
}
/// This is your "Error: 404 - not found" handler
#[handler]
async fn not_found_handler() -> impl Scribe {
AppError::NotFound
}
/// This is the first page your user hits, meaning it does not contain language information,
/// so we redirect them.
#[handler]
async fn start_handler() -> impl Scribe {
Redirect::found("/en/index.html")
}
/// This is the first localized page your user sees.
///
/// It has arguments in the path that need to be parsable using `serde::Deserialize`; see `Lang`
/// for an explanation. And also query parameters (anything after `?` in the incoming URL).
#[handler]
async fn index_handler(req: &mut Request) -> Result<impl Scribe, AppError> {
/// This type collects the URL params, i.e. the `"/{lang}/"` part
#[derive(Debug, Deserialize, Extractible)]
#[salvo(extract(default_source(from = "param")))]
struct Params {
lang: Lang,
}
/// This type collects the query parameter `?name=` (if present)
#[derive(Debug, Deserialize, Extractible)]
#[salvo(extract(default_source(from = "query")))]
struct Query {
#[serde(default)]
name: String,
}
let Params { lang } = req.extract().await?;
let Query { name } = req.extract().await?;
// In the template we both use `{% match lang %}` and `{% if lang !=`, the former to select the
// text of a specific language, e.g. in the `<title>`; and the latter to display references to
// all other available languages except the currently selected one.
// The field `name` will contain the value of the query parameter of the same name.
// In `IndexHandlerQuery` we annotated the field with `#[serde(default)]`, so if the value is
// absent, an empty string is selected by default, which is visible to the user an empty
// `<input type="text" />` element.
#[derive(Debug, Template)]
#[template(path = "index.html")]
struct Tmpl {
lang: Lang,
name: String,
}
let template = Tmpl { lang, name };
Ok(Text::Html(template.render()?))
}
/// This is the final page of this example application.
///
/// Like `index_handler` it contains a language in the URL, and a query parameter to read the user's
/// provided name. In here, the query argument `name` has no default value, so salvo will show
/// an error message if absent.
#[handler]
async fn greeting_handler(req: &mut Request) -> Result<impl Scribe, AppError> {
#[derive(Debug, Deserialize, Extractible)]
#[salvo(extract(default_source(from = "param")))]
struct Params {
lang: Lang,
}
#[derive(Debug, Deserialize, Extractible)]
#[salvo(extract(default_source(from = "query")))]
struct Query {
name: String,
}
let Params { lang } = req.extract().await?;
let Query { name } = req.extract().await?;
#[derive(Debug, Template)]
#[template(path = "greet.html")]
struct Tmpl {
lang: Lang,
name: String,
}
let template = Tmpl { lang, name };
Ok(Text::Html(template.render()?))
}

View File

@ -0,0 +1,56 @@
{#-
This file is included by "_layout.html".
You can still use template syntax (such as this comment) in here.
-#}
html {
background-color: #eee;
color: #111;
font-size: 62.5%;
min-height: 100vh;
color-scheme: light;
}
* {
line-height: 1.2em;
}
body {
background-color: #fff;
font-size: 1.8rem;
max-width: 40em;
margin: 1em auto;
padding: 2em;
}
h1 { font-size: 2.4rem; }
h2 { font-size: 2.2rem; }
h3 { font-size: 2.0rem; }
a:link, a:visited {
color: #36c;
text-decoration: none;
}
a:active, a:hover, a:focus {
text-decoration: underline;
text-underline-offset: 0.3em;
}
#lang-select {
font-size: 80%;
width: max-content;
margin: 2em 0 0 auto;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
#lang-select li {
flex-grow: 1;
flex-basis: auto;
margin: .25em 0 0 0;
padding: 0 1em;
text-align: center;
list-style-type: none;
border-left: 0.1rem solid currentColor;
}
#lang-select li:first-of-type {
border-left: 0 none transparent;
}
#lang-select li:last-of-type {
padding-right: 0;
}

View File

@ -0,0 +1,67 @@
{#-
This is the basic layout of our example application.
It is the core skeleton shared between all pages.
It expects the struct of any template that `{% extends %}` this layout to contain
(at least) a field `lang: Lang`, so it can be used in the `<html lang=` attribute.
-#}
<!DOCTYPE html>
<html lang="{{lang}}">
<head>
<meta charset="UTF-8" />
{#-
A base template can contain `blocks`, which my be overridden templates that use
this base template. A block may contain a default content, if the extending
template does want to / need to override the content of a block.
E.g. maybe you would like to have "Rinja example application" as default title for
your pages, then simply add this text (without quotation marks) in the block!
The default content can be as complex as you need it to be.
E.g. it may contain any nodes like `{% if … %}`, and even other blocks.
~#}
<title>{% block title %}{% endblock %}</title>
<meta http-equiv="expires" content="Sat, 01 Dec 2001 00:00:00 GMT" />
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="pragma" content="no-cache" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
{#-
In a real application you most likely would want to link style sheets,
any JavaScripts etc. using e.g. `actix-files`, instead of embedding the content
in your generated HTML.
As you can see, this comment starts with `-`, which will tells the comment
to strip all white spaces before it, until it finds the first non-white space
character, a `>`.
The comment is also terminated with `~`. This also strips white spaces, but
will leave one space, or a newline character, if the stripped content contained
a newline.
~#}
<style>
/*<![CDATA[*/
{%~ include "_layout.css" ~%}
/*]]>*/
</style>
</head>
<body>
{%~ block content %}{% endblock ~%}
</body>
</html>
{%- macro lang_select(page, query="") -%}
<ul id="lang-select">
{%- if lang != Lang::en -%}
<li><a href="/en/{{ page }}.html{{ query }}">This page in English</a></li>
{%- endif -%}
{%- if lang != Lang::de -%}
<li><a href="/de/{{ page }}.html{{ query }}">Diese Seite auf deutsch.</a></li>
{%- endif -%}
{%- if lang != Lang::fr -%}
<li><a href="/fr/{{ page }}.html{{ query }}">Cette page est en français.</a></li>
{%- endif -%}
</ul>
{%- endmacro lang_select -%}

View File

@ -0,0 +1,27 @@
{% extends "_layout.html" %}
{%- block title -%}
{%- match err -%}
{% when AppError::NotFound -%} 404: Not Found
{% when AppError::Extract(_) -%} 422: Unprocessable Entity
{% when AppError::Render(_) -%} 500: Internal Server Error
{%- endmatch -%}
{%- endblock -%}
{%- block content -%}
<h1>
{%- match err -%}
{% when AppError::NotFound -%} 404: Not Found
{% when AppError::Extract(_) -%} 422: Unprocessable Entity
{% when AppError::Render(_) -%} 500: Internal Server Error
{%- endmatch -%}
</h1>
{%- match err -%}
{% when AppError::NotFound -%}
{% when AppError::Extract(_) -%} <pre>{{ err }}</pre>
{% when AppError::Render(err) -%} <pre>{{ err }}</pre>
{%- endmatch -%}
<h2><a href="/">Back to the first page.</a></h2>
{%- endblock -%}

View File

@ -0,0 +1,43 @@
{% extends "_layout.html" %}
{%- block title -%}
{%- match lang -%}
{%- when Lang::en -%} Hello, {{name}}!
{%- when Lang::de -%} Hallo, {{name}}!
{%- when Lang::fr -%} Bonjour, {{name}}!
{%- endmatch -%}
{%- endblock -%}
{%- block content -%}
<h1>
{%- match lang -%}
{%- when Lang::en -%} Hello!
{%- when Lang::de -%} Hallo!
{%- when Lang::fr -%} Bonjour!
{%- endmatch -%}
</h1>
<p>
{%- match lang -%}
{%- when Lang::en -%}
Hello, <strong>{{name}}</strong>, nice to meet you! {#-~#}
I'm a <a href="https://rinja.readthedocs.io/">Rinja</a> example application.
{%- when Lang::de -%}
Hallo, <strong>{{name}}</strong>, schön dich kennenzulernen! {#-~#}
Ich bin eine <a href="https://rinja.readthedocs.io/">Rinja</a>-Beispielanwendung.
{%- when Lang::fr -%}
Bonjour, <strong>{{name}}</strong>, ravi de vous rencontrer ! {#-~#}
Je suis une application d'exemple de <a href="https://rinja.readthedocs.io/">Rinja</a>.
{%- endmatch -%}
</p>
<h2>
<a href="/{{ lang }}/index.html?name={{ name | urlencode }}">
{%- match lang -%}
{%- when Lang::en -%} Back to the first page.
{%- when Lang::de -%} Zurück zur ersten Seite.
{%- when Lang::fr -%} Retour à la première page.
{%- endmatch -%}
</a>
</h2>
{%- call lang_select("greet-me", name|urlencode|fmt("?name={}")) -%}
{%- endblock -%}

View File

@ -0,0 +1,84 @@
{% extends "_layout.html" %}
{%- block title -%}
{#-
In here you can see how to use the language URL compment to select the text to display.
-#}
{%- match lang -%}
{%- when Lang::en -%} Hello!
{%- when Lang::de -%} Hallo!
{%- when Lang::fr -%} Bonjour!
{%- endmatch -%}
{%- endblock -%}
{%- block content -%}
<h1>
{%- match lang -%}
{%- when Lang::en -%} Hello!
{%- when Lang::de -%} Hallo!
{%- when Lang::fr -%} Bonjour!
{%- endmatch -%}
</h1>
<form
method="GET"
action="/{{ lang }}/greet-me.html"
autocomplete="off"
>
<p>
{#-
If your text contains long lines, you may want to split them,
so you as a developer have an easier time reading them.
If you don't want to end up with a multitude of spaces in the
generated content, you can use empty comments as seen below: `#-~#`.
This would strip the space before the comment, and leave a newline
character after the comment. Another option would be `#~-#`,
so that single space remains.
-#}
{%- match lang -%}
{%- when Lang::en -%}
I would like to say <em>hello</em>. {#-~#}
Would you please tell me your name?
{%- when Lang::de -%}
Ich möchte dir gerne <em>hallo</em> sagen. {#-~#}
Bitte nenne mir doch deinen Namen!
{%- when Lang::fr -%}
Je voudrais vous dire <em>bonjour</em>. {#-~#}
Pourriez-vous me donner votre nom ?
{%- endmatch -%}
</p>
<p>
<label>
{%- match lang -%}
{%- when Lang::en -%} My name is
{%- when Lang::de -%} Ich heiße
{%- when Lang::fr -%} Je m'appelle
{%- endmatch -%}:
<input
type="text"
value="{{name}}"
name="name"
required
style="width: 10em"
/>
</label>
</p>
<p>
<label>
<button type="submit">
{%- match lang -%}
{%- when Lang::en -%} Greet me, then!
{%- when Lang::de -%} Dann begrüße mich!
{%- when Lang::fr -%} Saluons-nous !
{%- endmatch -%}
</button>
</label>
</p>
</form>
{#-
The called macro is defined in base template "_layout.html",
and used to display the language selection footer.
~#}
{%- call lang_select("index") -%}
{%- endblock -%}

View File

@ -0,0 +1 @@
../../tomlfmt.toml