From 4577e7e29728c7ff98b9f88dc7ec7928da1132b9 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 27 Aug 2023 18:52:33 +0200 Subject: [PATCH 01/24] Add api for UserLanguage Include example showing basic usage --- axum-extra/src/extract/mod.rs | 6 ++++- axum-extra/src/extract/user_lang.rs | 40 +++++++++++++++++++++++++++++ examples/user-language/Cargo.toml | 10 ++++++++ examples/user-language/src/main.rs | 37 ++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 axum-extra/src/extract/user_lang.rs create mode 100644 examples/user-language/Cargo.toml create mode 100644 examples/user-language/src/main.rs diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index c7946413a8..9d47a19276 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -2,6 +2,7 @@ mod cached; mod optional_path; +mod user_lang; mod with_rejection; #[cfg(feature = "form")] @@ -16,7 +17,10 @@ mod query; #[cfg(feature = "multipart")] pub mod multipart; -pub use self::{cached::Cached, optional_path::OptionalPath, with_rejection::WithRejection}; +pub use self::{ + cached::Cached, optional_path::OptionalPath, user_lang::UserLanguage, + with_rejection::WithRejection, +}; #[cfg(feature = "cookie")] pub use self::cookie::CookieJar; diff --git a/axum-extra/src/extract/user_lang.rs b/axum-extra/src/extract/user_lang.rs new file mode 100644 index 0000000000..bfb8145e4d --- /dev/null +++ b/axum-extra/src/extract/user_lang.rs @@ -0,0 +1,40 @@ +use axum::{async_trait, extract::FromRequestParts}; +use http::request::Parts; + +/// TBD +#[derive(Debug, Clone)] +pub struct UserLanguage { + preferred_languages: Vec, + fallback_language: String, +} + +impl UserLanguage { + /// TBD + pub fn preferred_language(&self) -> &str { + self.preferred_languages + .first() + .unwrap_or(&self.fallback_language) + } + + /// TBD + pub fn preferred_languages(&self) -> &[String] { + self.preferred_languages.as_slice() + } + + /// TBD + pub fn fallback_language(&self) -> &str { + &self.fallback_language + } +} + +#[async_trait] +impl FromRequestParts for UserLanguage +where + S: Send + Sync, +{ + type Rejection = (); + + async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { + todo!() + } +} diff --git a/examples/user-language/Cargo.toml b/examples/user-language/Cargo.toml new file mode 100644 index 0000000000..000e3604fd --- /dev/null +++ b/examples/user-language/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "example-user-language" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +axum = { path = "../../axum" } +axum-extra = { path = "../../axum-extra" } +tokio = { version = "1.0", features = ["full"] } diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs new file mode 100644 index 0000000000..65542b0c39 --- /dev/null +++ b/examples/user-language/src/main.rs @@ -0,0 +1,37 @@ +//! Run with +//! +//! ```not_rust +//! cargo run -p example-user-language +//! ``` + +use axum::{response::Html, routing::get, Router}; +use axum_extra::extract::UserLanguage; + +#[tokio::main] +async fn main() { + // build our application with a route + let app = Router::new() + .route("/", get(handler)) + .route("/:lang", get(handler)); + + // run it + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .unwrap(); + println!("listening on {}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} + +async fn handler(lang: UserLanguage) -> Html<&'static str> { + println!( + "User prefers content in the following languages (in order): {:?}", + lang.preferred_languages() + ); + + match lang.preferred_language() { + "de" => Html("

Hallo, Welt!

"), + "es" => Html("

Hola, Mundo!

"), + "fr" => Html("

Bonjour, le monde!

"), + _ => Html("

Hello, World!

"), + } +} From 2eb08a17afe8fca6655a1a6cd6e216c486162fba Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 27 Aug 2023 19:07:26 +0000 Subject: [PATCH 02/24] Add a first implementation to read user language --- axum-extra/src/extract/user_lang.rs | 67 +++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/axum-extra/src/extract/user_lang.rs b/axum-extra/src/extract/user_lang.rs index bfb8145e4d..ca17944fdb 100644 --- a/axum-extra/src/extract/user_lang.rs +++ b/axum-extra/src/extract/user_lang.rs @@ -1,5 +1,10 @@ -use axum::{async_trait, extract::FromRequestParts}; +use axum::{ + async_trait, + extract::{FromRequestParts, Path, Query}, + RequestPartsExt, +}; use http::request::Parts; +use std::{cmp::Ordering, collections::HashMap, convert::Infallible}; /// TBD #[derive(Debug, Clone)] @@ -32,9 +37,63 @@ impl FromRequestParts for UserLanguage where S: Send + Sync, { - type Rejection = (); + type Rejection = Infallible; - async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { - todo!() + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let mut preferred_languages = Vec::::new(); + + // First try to get the language from the query string + if let Ok(query) = parts.extract::>>().await { + if let Some(lang) = query.get("lang") { + preferred_languages.push(lang.to_string()); + } + }; + + // Then try to get the language from the path + if let Ok(path) = parts.extract::>>().await { + if let Some(lang) = path.get("lang") { + preferred_languages.push(lang.to_string()); + } + }; + + // Then try to get the language from the Accept-Language header + if let Some(accept_language) = parts.headers.get("Accept-Language") { + if let Ok(accept_language) = accept_language.to_str() { + for (lang, _) in parse_quality_values(accept_language) { + preferred_languages.push(lang.to_string()); + } + } + } + + Ok(UserLanguage { + preferred_languages, + fallback_language: "en".to_string(), + }) + } +} + +fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { + let mut values = values.split(','); + let mut quality_values = Vec::new(); + + while let Some(value) = values.next() { + let mut value = value.trim().split(';'); + let (value, quality) = (value.next(), value.next()); + + let Some(value) = value else { + // empty quality value entry + continue; + }; + + let quality = if let Some(quality) = quality.and_then(|q| q.strip_prefix("q=")) { + quality.parse::().unwrap_or(0.0) + } else { + 1.0 + }; + + quality_values.push((value, quality)); } + + quality_values.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); + quality_values } From ca79cfba21400191e865c41debd69ad524a8174f Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 27 Aug 2023 19:43:34 +0000 Subject: [PATCH 03/24] Extract user lang sources --- axum-extra/src/extract/mod.rs | 4 +- axum-extra/src/extract/user_lang.rs | 99 ------------------- axum-extra/src/extract/user_lang/lang.rs | 69 +++++++++++++ axum-extra/src/extract/user_lang/mod.rs | 8 ++ axum-extra/src/extract/user_lang/source.rs | 9 ++ .../src/extract/user_lang/sources/header.rs | 56 +++++++++++ .../src/extract/user_lang/sources/mod.rs | 7 ++ .../src/extract/user_lang/sources/path.rs | 36 +++++++ .../src/extract/user_lang/sources/query.rs | 36 +++++++ 9 files changed, 224 insertions(+), 100 deletions(-) delete mode 100644 axum-extra/src/extract/user_lang.rs create mode 100644 axum-extra/src/extract/user_lang/lang.rs create mode 100644 axum-extra/src/extract/user_lang/mod.rs create mode 100644 axum-extra/src/extract/user_lang/source.rs create mode 100644 axum-extra/src/extract/user_lang/sources/header.rs create mode 100644 axum-extra/src/extract/user_lang/sources/mod.rs create mode 100644 axum-extra/src/extract/user_lang/sources/path.rs create mode 100644 axum-extra/src/extract/user_lang/sources/query.rs diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 9d47a19276..0889517dba 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -18,7 +18,9 @@ mod query; pub mod multipart; pub use self::{ - cached::Cached, optional_path::OptionalPath, user_lang::UserLanguage, + cached::Cached, + optional_path::OptionalPath, + user_lang::{sources, UserLanguage, UserLanguageSource}, with_rejection::WithRejection, }; diff --git a/axum-extra/src/extract/user_lang.rs b/axum-extra/src/extract/user_lang.rs deleted file mode 100644 index ca17944fdb..0000000000 --- a/axum-extra/src/extract/user_lang.rs +++ /dev/null @@ -1,99 +0,0 @@ -use axum::{ - async_trait, - extract::{FromRequestParts, Path, Query}, - RequestPartsExt, -}; -use http::request::Parts; -use std::{cmp::Ordering, collections::HashMap, convert::Infallible}; - -/// TBD -#[derive(Debug, Clone)] -pub struct UserLanguage { - preferred_languages: Vec, - fallback_language: String, -} - -impl UserLanguage { - /// TBD - pub fn preferred_language(&self) -> &str { - self.preferred_languages - .first() - .unwrap_or(&self.fallback_language) - } - - /// TBD - pub fn preferred_languages(&self) -> &[String] { - self.preferred_languages.as_slice() - } - - /// TBD - pub fn fallback_language(&self) -> &str { - &self.fallback_language - } -} - -#[async_trait] -impl FromRequestParts for UserLanguage -where - S: Send + Sync, -{ - type Rejection = Infallible; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let mut preferred_languages = Vec::::new(); - - // First try to get the language from the query string - if let Ok(query) = parts.extract::>>().await { - if let Some(lang) = query.get("lang") { - preferred_languages.push(lang.to_string()); - } - }; - - // Then try to get the language from the path - if let Ok(path) = parts.extract::>>().await { - if let Some(lang) = path.get("lang") { - preferred_languages.push(lang.to_string()); - } - }; - - // Then try to get the language from the Accept-Language header - if let Some(accept_language) = parts.headers.get("Accept-Language") { - if let Ok(accept_language) = accept_language.to_str() { - for (lang, _) in parse_quality_values(accept_language) { - preferred_languages.push(lang.to_string()); - } - } - } - - Ok(UserLanguage { - preferred_languages, - fallback_language: "en".to_string(), - }) - } -} - -fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { - let mut values = values.split(','); - let mut quality_values = Vec::new(); - - while let Some(value) = values.next() { - let mut value = value.trim().split(';'); - let (value, quality) = (value.next(), value.next()); - - let Some(value) = value else { - // empty quality value entry - continue; - }; - - let quality = if let Some(quality) = quality.and_then(|q| q.strip_prefix("q=")) { - quality.parse::().unwrap_or(0.0) - } else { - 1.0 - }; - - quality_values.push((value, quality)); - } - - quality_values.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); - quality_values -} diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs new file mode 100644 index 0000000000..d3d8952b18 --- /dev/null +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -0,0 +1,69 @@ +use super::{ + sources::{AcceptLanguageSource, PathSource, QuerySource}, + UserLanguageSource, +}; +use axum::{async_trait, extract::FromRequestParts}; +use http::request::Parts; +use std::convert::Infallible; + +/// TBD +#[derive(Debug, Clone)] +pub struct UserLanguage { + preferred_languages: Vec, + fallback_language: String, +} + +impl UserLanguage { + /// TBD + pub fn default_sources() -> Vec>> + where + S: Send + Sync, + { + vec![ + Box::new(QuerySource::new("lang")), + Box::new(PathSource::new("lang")), + Box::new(AcceptLanguageSource), + ] + } + + /// TBD + pub fn preferred_language(&self) -> &str { + self.preferred_languages + .first() + .unwrap_or(&self.fallback_language) + } + + /// TBD + pub fn preferred_languages(&self) -> &[String] { + self.preferred_languages.as_slice() + } + + /// TBD + pub fn fallback_language(&self) -> &str { + &self.fallback_language + } +} + +#[async_trait] +impl FromRequestParts for UserLanguage +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let sources = Self::default_sources::(); + + let mut preferred_languages = Vec::::new(); + + for source in sources { + let languages = source.languages_from_parts(parts, state).await; + preferred_languages.extend(languages); + } + + Ok(UserLanguage { + preferred_languages, + fallback_language: "en".to_string(), + }) + } +} diff --git a/axum-extra/src/extract/user_lang/mod.rs b/axum-extra/src/extract/user_lang/mod.rs new file mode 100644 index 0000000000..1343006d05 --- /dev/null +++ b/axum-extra/src/extract/user_lang/mod.rs @@ -0,0 +1,8 @@ +mod lang; +mod source; + +/// TBD +pub mod sources; + +pub use lang::*; +pub use source::*; diff --git a/axum-extra/src/extract/user_lang/source.rs b/axum-extra/src/extract/user_lang/source.rs new file mode 100644 index 0000000000..adee1f6e3f --- /dev/null +++ b/axum-extra/src/extract/user_lang/source.rs @@ -0,0 +1,9 @@ +use axum::async_trait; +use http::request::Parts; + +/// TBD +#[async_trait] +pub trait UserLanguageSource: Send + Sync { + /// TBD + async fn languages_from_parts(&self, parts: &mut Parts, state: &S) -> Vec; +} diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs new file mode 100644 index 0000000000..1ce5a504ea --- /dev/null +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -0,0 +1,56 @@ +use std::cmp::Ordering; + +use crate::extract::UserLanguageSource; +use axum::async_trait; + +/// TBD +#[derive(Debug, Clone)] +pub struct AcceptLanguageSource; + +#[async_trait] +impl UserLanguageSource for AcceptLanguageSource { + async fn languages_from_parts( + &self, + parts: &mut http::request::Parts, + _state: &S, + ) -> Vec { + let Some(accept_language) = parts.headers.get("Accept-Language") else { + return vec![]; + }; + + let Ok(accept_language) = accept_language.to_str() else { + return vec![]; + }; + + parse_quality_values(accept_language) + .into_iter() + .map(|(lang, _)| lang.to_string()) + .collect() + } +} + +fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { + let mut values = values.split(','); + let mut quality_values = Vec::new(); + + while let Some(value) = values.next() { + let mut value = value.trim().split(';'); + let (value, quality) = (value.next(), value.next()); + + let Some(value) = value else { + // empty quality value entry + continue; + }; + + let quality = if let Some(quality) = quality.and_then(|q| q.strip_prefix("q=")) { + quality.parse::().unwrap_or(0.0) + } else { + 1.0 + }; + + quality_values.push((value, quality)); + } + + quality_values.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); + quality_values +} diff --git a/axum-extra/src/extract/user_lang/sources/mod.rs b/axum-extra/src/extract/user_lang/sources/mod.rs new file mode 100644 index 0000000000..b166f9a738 --- /dev/null +++ b/axum-extra/src/extract/user_lang/sources/mod.rs @@ -0,0 +1,7 @@ +mod header; +mod path; +mod query; + +pub use header::*; +pub use path::*; +pub use query::*; diff --git a/axum-extra/src/extract/user_lang/sources/path.rs b/axum-extra/src/extract/user_lang/sources/path.rs new file mode 100644 index 0000000000..7e751a677d --- /dev/null +++ b/axum-extra/src/extract/user_lang/sources/path.rs @@ -0,0 +1,36 @@ +use crate::extract::UserLanguageSource; +use axum::{async_trait, extract::Path, RequestPartsExt}; +use std::collections::HashMap; + +/// TBD +#[derive(Debug, Clone)] +pub struct PathSource { + /// TBD + name: String, +} + +impl PathSource { + /// TBD + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +#[async_trait] +impl UserLanguageSource for PathSource { + async fn languages_from_parts( + &self, + parts: &mut http::request::Parts, + _state: &S, + ) -> Vec { + let Ok(path) = parts.extract::>>().await else { + return vec![]; + }; + + let Some(lang) = path.get(self.name.as_str()) else { + return vec![]; + }; + + vec![lang.to_string()] + } +} diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs new file mode 100644 index 0000000000..e74c119394 --- /dev/null +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -0,0 +1,36 @@ +use crate::extract::UserLanguageSource; +use axum::{async_trait, extract::Query, RequestPartsExt}; +use std::collections::HashMap; + +/// TBD +#[derive(Debug, Clone)] +pub struct QuerySource { + /// TBD + name: String, +} + +impl QuerySource { + /// TBD + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +#[async_trait] +impl UserLanguageSource for QuerySource { + async fn languages_from_parts( + &self, + parts: &mut http::request::Parts, + _state: &S, + ) -> Vec { + let Ok(query) = parts.extract::>>().await else { + return vec![]; + }; + + let Some(lang) = query.get(self.name.as_str()) else { + return vec![]; + }; + + vec![lang.to_string()] + } +} From e2c538b5955e42cd960301c14313736c6b18a9ec Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 27 Aug 2023 20:30:46 +0000 Subject: [PATCH 04/24] Add possibility to configure --- axum-extra/src/extract/mod.rs | 2 +- axum-extra/src/extract/user_lang/config.rs | 52 +++++++++++++++++++ axum-extra/src/extract/user_lang/lang.rs | 43 +++++++++------ axum-extra/src/extract/user_lang/mod.rs | 2 + axum-extra/src/extract/user_lang/source.rs | 5 +- .../src/extract/user_lang/sources/header.rs | 8 +-- .../src/extract/user_lang/sources/path.rs | 8 +-- .../src/extract/user_lang/sources/query.rs | 8 +-- examples/user-language/src/main.rs | 11 ++-- 9 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 axum-extra/src/extract/user_lang/config.rs diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 0889517dba..cdde256e19 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -20,7 +20,7 @@ pub mod multipart; pub use self::{ cached::Cached, optional_path::OptionalPath, - user_lang::{sources, UserLanguage, UserLanguageSource}, + user_lang::{sources, UserLanguage, UserLanguageConfig, UserLanguageSource}, with_rejection::WithRejection, }; diff --git a/axum-extra/src/extract/user_lang/config.rs b/axum-extra/src/extract/user_lang/config.rs new file mode 100644 index 0000000000..43e0e3980d --- /dev/null +++ b/axum-extra/src/extract/user_lang/config.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use super::{UserLanguage, UserLanguageSource}; + +/// TBD +#[derive(Debug, Clone)] +pub struct UserLanguageConfig { + /// TBD + pub fallback_language: String, + + /// TBD + pub sources: Vec>, +} + +#[derive(Debug, Clone)] +pub struct UserLanguageConfigBuilder { + fallback_language: String, + sources: Vec>, +} + +impl UserLanguageConfigBuilder { + pub fn fallback_language(mut self, fallback_language: impl Into) -> Self { + self.fallback_language = fallback_language.into(); + self + } + + pub fn add_source(mut self, source: impl UserLanguageSource + 'static) -> Self { + self.sources.push(Arc::new(source)); + self + } + + pub fn build(self) -> UserLanguageConfig { + UserLanguageConfig { + fallback_language: self.fallback_language, + sources: if !self.sources.is_empty() { + self.sources + } else { + UserLanguage::default_sources().clone() + }, + } + } +} + +impl UserLanguage { + /// TBD + pub fn config() -> UserLanguageConfigBuilder { + UserLanguageConfigBuilder { + fallback_language: "en".to_string(), + sources: vec![], + } + } +} diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index d3d8952b18..aeaeb3ad84 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -1,10 +1,13 @@ use super::{ sources::{AcceptLanguageSource, PathSource, QuerySource}, - UserLanguageSource, + UserLanguageConfig, UserLanguageSource, }; -use axum::{async_trait, extract::FromRequestParts}; +use axum::{async_trait, extract::FromRequestParts, Extension, RequestPartsExt}; use http::request::Parts; -use std::convert::Infallible; +use std::{ + convert::Infallible, + sync::{Arc, OnceLock}, +}; /// TBD #[derive(Debug, Clone)] @@ -15,15 +18,16 @@ pub struct UserLanguage { impl UserLanguage { /// TBD - pub fn default_sources() -> Vec>> - where - S: Send + Sync, - { - vec![ - Box::new(QuerySource::new("lang")), - Box::new(PathSource::new("lang")), - Box::new(AcceptLanguageSource), - ] + pub fn default_sources() -> &'static Vec> { + static DEFAULT_SOURCES: OnceLock>> = OnceLock::new(); + + DEFAULT_SOURCES.get_or_init(|| { + vec![ + Arc::new(QuerySource::new("lang")), + Arc::new(PathSource::new("lang")), + Arc::new(AcceptLanguageSource), + ] + }) } /// TBD @@ -51,19 +55,26 @@ where { type Rejection = Infallible; - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let sources = Self::default_sources::(); + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let (sources, fallback_language) = + match parts.extract::>().await { + Ok(Extension(config)) => (Some(config.sources), Some(config.fallback_language)), + Err(_) => (None, None), + }; + + let sources = sources.as_ref().unwrap_or(Self::default_sources()); + let fallback_language = fallback_language.unwrap_or_else(|| "en".to_string()); let mut preferred_languages = Vec::::new(); for source in sources { - let languages = source.languages_from_parts(parts, state).await; + let languages = source.languages_from_parts(parts).await; preferred_languages.extend(languages); } Ok(UserLanguage { preferred_languages, - fallback_language: "en".to_string(), + fallback_language, }) } } diff --git a/axum-extra/src/extract/user_lang/mod.rs b/axum-extra/src/extract/user_lang/mod.rs index 1343006d05..db62a41306 100644 --- a/axum-extra/src/extract/user_lang/mod.rs +++ b/axum-extra/src/extract/user_lang/mod.rs @@ -1,8 +1,10 @@ +mod config; mod lang; mod source; /// TBD pub mod sources; +pub use config::*; pub use lang::*; pub use source::*; diff --git a/axum-extra/src/extract/user_lang/source.rs b/axum-extra/src/extract/user_lang/source.rs index adee1f6e3f..32594fb9ca 100644 --- a/axum-extra/src/extract/user_lang/source.rs +++ b/axum-extra/src/extract/user_lang/source.rs @@ -1,9 +1,10 @@ use axum::async_trait; use http::request::Parts; +use std::fmt::Debug; /// TBD #[async_trait] -pub trait UserLanguageSource: Send + Sync { +pub trait UserLanguageSource: Send + Sync + Debug { /// TBD - async fn languages_from_parts(&self, parts: &mut Parts, state: &S) -> Vec; + async fn languages_from_parts(&self, parts: &mut Parts) -> Vec; } diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs index 1ce5a504ea..d623b264b6 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -8,12 +8,8 @@ use axum::async_trait; pub struct AcceptLanguageSource; #[async_trait] -impl UserLanguageSource for AcceptLanguageSource { - async fn languages_from_parts( - &self, - parts: &mut http::request::Parts, - _state: &S, - ) -> Vec { +impl UserLanguageSource for AcceptLanguageSource { + async fn languages_from_parts(&self, parts: &mut http::request::Parts) -> Vec { let Some(accept_language) = parts.headers.get("Accept-Language") else { return vec![]; }; diff --git a/axum-extra/src/extract/user_lang/sources/path.rs b/axum-extra/src/extract/user_lang/sources/path.rs index 7e751a677d..d8904432c5 100644 --- a/axum-extra/src/extract/user_lang/sources/path.rs +++ b/axum-extra/src/extract/user_lang/sources/path.rs @@ -17,12 +17,8 @@ impl PathSource { } #[async_trait] -impl UserLanguageSource for PathSource { - async fn languages_from_parts( - &self, - parts: &mut http::request::Parts, - _state: &S, - ) -> Vec { +impl UserLanguageSource for PathSource { + async fn languages_from_parts(&self, parts: &mut http::request::Parts) -> Vec { let Ok(path) = parts.extract::>>().await else { return vec![]; }; diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs index e74c119394..b46a168a5e 100644 --- a/axum-extra/src/extract/user_lang/sources/query.rs +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -17,12 +17,8 @@ impl QuerySource { } #[async_trait] -impl UserLanguageSource for QuerySource { - async fn languages_from_parts( - &self, - parts: &mut http::request::Parts, - _state: &S, - ) -> Vec { +impl UserLanguageSource for QuerySource { + async fn languages_from_parts(&self, parts: &mut http::request::Parts) -> Vec { let Ok(query) = parts.extract::>>().await else { return vec![]; }; diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs index 65542b0c39..62dffe5a0a 100644 --- a/examples/user-language/src/main.rs +++ b/examples/user-language/src/main.rs @@ -4,15 +4,20 @@ //! cargo run -p example-user-language //! ``` -use axum::{response::Html, routing::get, Router}; -use axum_extra::extract::UserLanguage; +use axum::{response::Html, routing::get, Extension, Router}; +use axum_extra::extract::{sources::QuerySource, UserLanguage}; #[tokio::main] async fn main() { // build our application with a route let app = Router::new() .route("/", get(handler)) - .route("/:lang", get(handler)); + .route("/:lang", get(handler)) + .layer(Extension( + UserLanguage::config() + .add_source(QuerySource::new("lang")) + .build(), + )); // run it let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") From 55232bd870431863e063382d714f8752bc022d8f Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 27 Aug 2023 22:33:31 +0200 Subject: [PATCH 05/24] Improve UserLanguage example a bit --- examples/user-language/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs index 62dffe5a0a..6f6de7820a 100644 --- a/examples/user-language/src/main.rs +++ b/examples/user-language/src/main.rs @@ -5,7 +5,10 @@ //! ``` use axum::{response::Html, routing::get, Extension, Router}; -use axum_extra::extract::{sources::QuerySource, UserLanguage}; +use axum_extra::extract::{ + sources::{PathSource, QuerySource}, + UserLanguage, +}; #[tokio::main] async fn main() { @@ -16,6 +19,7 @@ async fn main() { .layer(Extension( UserLanguage::config() .add_source(QuerySource::new("lang")) + .add_source(PathSource::new("lang")) .build(), )); @@ -37,6 +41,6 @@ async fn handler(lang: UserLanguage) -> Html<&'static str> { "de" => Html("

Hallo, Welt!

"), "es" => Html("

Hola, Mundo!

"), "fr" => Html("

Bonjour, le monde!

"), - _ => Html("

Hello, World!

"), + "en" | _ => Html("

Hello, World!

"), } } From d4ce66b67f3938113b00dfce7de64dc124b2a1fc Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 27 Aug 2023 20:49:48 +0000 Subject: [PATCH 06/24] Reorganize user_lang modules --- axum-extra/src/extract/mod.rs | 10 +++------- axum-extra/src/lib.rs | 1 + axum-extra/src/{extract => }/user_lang/config.rs | 7 +++++-- axum-extra/src/{extract => }/user_lang/lang.rs | 0 axum-extra/src/{extract => }/user_lang/mod.rs | 7 ++++--- axum-extra/src/{extract => }/user_lang/source.rs | 0 .../src/{extract => }/user_lang/sources/header.rs | 5 ++--- axum-extra/src/{extract => }/user_lang/sources/mod.rs | 0 axum-extra/src/{extract => }/user_lang/sources/path.rs | 2 +- .../src/{extract => }/user_lang/sources/query.rs | 2 +- examples/user-language/src/main.rs | 6 +++--- 11 files changed, 20 insertions(+), 20 deletions(-) rename axum-extra/src/{extract => }/user_lang/config.rs (92%) rename axum-extra/src/{extract => }/user_lang/lang.rs (100%) rename axum-extra/src/{extract => }/user_lang/mod.rs (68%) rename axum-extra/src/{extract => }/user_lang/source.rs (100%) rename axum-extra/src/{extract => }/user_lang/sources/header.rs (97%) rename axum-extra/src/{extract => }/user_lang/sources/mod.rs (100%) rename axum-extra/src/{extract => }/user_lang/sources/path.rs (94%) rename axum-extra/src/{extract => }/user_lang/sources/query.rs (94%) diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index cdde256e19..812cf698fd 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -2,7 +2,6 @@ mod cached; mod optional_path; -mod user_lang; mod with_rejection; #[cfg(feature = "form")] @@ -17,12 +16,7 @@ mod query; #[cfg(feature = "multipart")] pub mod multipart; -pub use self::{ - cached::Cached, - optional_path::OptionalPath, - user_lang::{sources, UserLanguage, UserLanguageConfig, UserLanguageSource}, - with_rejection::WithRejection, -}; +pub use self::{cached::Cached, optional_path::OptionalPath, with_rejection::WithRejection}; #[cfg(feature = "cookie")] pub use self::cookie::CookieJar; @@ -49,3 +43,5 @@ pub use crate::json_lines::JsonLines; #[cfg(feature = "typed-header")] #[doc(no_inline)] pub use crate::typed_header::TypedHeader; + +pub use crate::user_lang::UserLanguage; diff --git a/axum-extra/src/lib.rs b/axum-extra/src/lib.rs index e6f9e87981..0f11110743 100644 --- a/axum-extra/src/lib.rs +++ b/axum-extra/src/lib.rs @@ -77,6 +77,7 @@ pub mod handler; pub mod middleware; pub mod response; pub mod routing; +pub mod user_lang; #[cfg(feature = "json-lines")] pub mod json_lines; diff --git a/axum-extra/src/extract/user_lang/config.rs b/axum-extra/src/user_lang/config.rs similarity index 92% rename from axum-extra/src/extract/user_lang/config.rs rename to axum-extra/src/user_lang/config.rs index 43e0e3980d..ade0484a43 100644 --- a/axum-extra/src/extract/user_lang/config.rs +++ b/axum-extra/src/user_lang/config.rs @@ -1,7 +1,6 @@ +use crate::user_lang::{UserLanguage, UserLanguageSource}; use std::sync::Arc; -use super::{UserLanguage, UserLanguageSource}; - /// TBD #[derive(Debug, Clone)] pub struct UserLanguageConfig { @@ -12,6 +11,7 @@ pub struct UserLanguageConfig { pub sources: Vec>, } +/// TBD #[derive(Debug, Clone)] pub struct UserLanguageConfigBuilder { fallback_language: String, @@ -19,16 +19,19 @@ pub struct UserLanguageConfigBuilder { } impl UserLanguageConfigBuilder { + /// TBD pub fn fallback_language(mut self, fallback_language: impl Into) -> Self { self.fallback_language = fallback_language.into(); self } + /// TBD pub fn add_source(mut self, source: impl UserLanguageSource + 'static) -> Self { self.sources.push(Arc::new(source)); self } + /// TBD pub fn build(self) -> UserLanguageConfig { UserLanguageConfig { fallback_language: self.fallback_language, diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/user_lang/lang.rs similarity index 100% rename from axum-extra/src/extract/user_lang/lang.rs rename to axum-extra/src/user_lang/lang.rs diff --git a/axum-extra/src/extract/user_lang/mod.rs b/axum-extra/src/user_lang/mod.rs similarity index 68% rename from axum-extra/src/extract/user_lang/mod.rs rename to axum-extra/src/user_lang/mod.rs index db62a41306..dfe04427e2 100644 --- a/axum-extra/src/extract/user_lang/mod.rs +++ b/axum-extra/src/user_lang/mod.rs @@ -1,10 +1,11 @@ +//! TBD + mod config; mod lang; mod source; - -/// TBD -pub mod sources; +mod sources; pub use config::*; pub use lang::*; pub use source::*; +pub use sources::*; diff --git a/axum-extra/src/extract/user_lang/source.rs b/axum-extra/src/user_lang/source.rs similarity index 100% rename from axum-extra/src/extract/user_lang/source.rs rename to axum-extra/src/user_lang/source.rs diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/user_lang/sources/header.rs similarity index 97% rename from axum-extra/src/extract/user_lang/sources/header.rs rename to axum-extra/src/user_lang/sources/header.rs index d623b264b6..e6c7492039 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/user_lang/sources/header.rs @@ -1,7 +1,6 @@ -use std::cmp::Ordering; - -use crate::extract::UserLanguageSource; +use crate::user_lang::UserLanguageSource; use axum::async_trait; +use std::cmp::Ordering; /// TBD #[derive(Debug, Clone)] diff --git a/axum-extra/src/extract/user_lang/sources/mod.rs b/axum-extra/src/user_lang/sources/mod.rs similarity index 100% rename from axum-extra/src/extract/user_lang/sources/mod.rs rename to axum-extra/src/user_lang/sources/mod.rs diff --git a/axum-extra/src/extract/user_lang/sources/path.rs b/axum-extra/src/user_lang/sources/path.rs similarity index 94% rename from axum-extra/src/extract/user_lang/sources/path.rs rename to axum-extra/src/user_lang/sources/path.rs index d8904432c5..1a80064260 100644 --- a/axum-extra/src/extract/user_lang/sources/path.rs +++ b/axum-extra/src/user_lang/sources/path.rs @@ -1,4 +1,4 @@ -use crate::extract::UserLanguageSource; +use crate::user_lang::UserLanguageSource; use axum::{async_trait, extract::Path, RequestPartsExt}; use std::collections::HashMap; diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/user_lang/sources/query.rs similarity index 94% rename from axum-extra/src/extract/user_lang/sources/query.rs rename to axum-extra/src/user_lang/sources/query.rs index b46a168a5e..5588454334 100644 --- a/axum-extra/src/extract/user_lang/sources/query.rs +++ b/axum-extra/src/user_lang/sources/query.rs @@ -1,4 +1,4 @@ -use crate::extract::UserLanguageSource; +use crate::user_lang::UserLanguageSource; use axum::{async_trait, extract::Query, RequestPartsExt}; use std::collections::HashMap; diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs index 6f6de7820a..58793aedba 100644 --- a/examples/user-language/src/main.rs +++ b/examples/user-language/src/main.rs @@ -5,9 +5,9 @@ //! ``` use axum::{response::Html, routing::get, Extension, Router}; -use axum_extra::extract::{ - sources::{PathSource, QuerySource}, - UserLanguage, +use axum_extra::{ + extract::UserLanguage, + user_lang::{PathSource, QuerySource}, }; #[tokio::main] From c40f4bf0360acb5b22490720c2c3446e8c229f61 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Thu, 28 Sep 2023 19:00:40 +0000 Subject: [PATCH 07/24] Move modules to be more consistent with the rest --- .devcontainer/devcontainer.json | 34 +++++++++++++++++++ .vscode/settings.json | 4 +++ axum-extra/src/extract/mod.rs | 6 ++-- .../src/{ => extract}/user_lang/config.rs | 3 +- .../src/{ => extract}/user_lang/lang.rs | 24 ++++++++++++- axum-extra/src/{ => extract}/user_lang/mod.rs | 2 +- .../src/{ => extract}/user_lang/source.rs | 0 .../{ => extract}/user_lang/sources/header.rs | 3 +- .../{ => extract}/user_lang/sources/mod.rs | 0 .../{ => extract}/user_lang/sources/path.rs | 3 +- .../{ => extract}/user_lang/sources/query.rs | 3 +- axum-extra/src/lib.rs | 1 - examples/user-language/src/main.rs | 7 +++- 13 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .vscode/settings.json rename axum-extra/src/{ => extract}/user_lang/config.rs (94%) rename axum-extra/src/{ => extract}/user_lang/lang.rs (77%) rename axum-extra/src/{ => extract}/user_lang/mod.rs (81%) rename axum-extra/src/{ => extract}/user_lang/source.rs (100%) rename axum-extra/src/{ => extract}/user_lang/sources/header.rs (96%) rename axum-extra/src/{ => extract}/user_lang/sources/mod.rs (100%) rename axum-extra/src/{ => extract}/user_lang/sources/path.rs (93%) rename axum-extra/src/{ => extract}/user_lang/sources/query.rs (93%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..60c7aa4105 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,34 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/rust +{ + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", + "features": { + "ghcr.io/devcontainers-contrib/features/fish-apt-get:1": {} + } + + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + // "mounts": [ + // { + // "source": "devcontainer-cargo-cache-${devcontainerId}", + // "target": "/usr/local/cargo", + // "type": "volume" + // } + // ] + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..4bc45ade3d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "rust-analyzer.showUnlinkedFileNotification": false, + "rust-analyzer.cargo.features": "all" +} \ No newline at end of file diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 812cf698fd..d8b09d6526 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -16,6 +16,8 @@ mod query; #[cfg(feature = "multipart")] pub mod multipart; +pub mod user_lang; + pub use self::{cached::Cached, optional_path::OptionalPath, with_rejection::WithRejection}; #[cfg(feature = "cookie")] @@ -36,6 +38,8 @@ pub use self::query::{Query, QueryRejection}; #[cfg(feature = "multipart")] pub use self::multipart::Multipart; +pub use self::user_lang::UserLanguage; + #[cfg(feature = "json-lines")] #[doc(no_inline)] pub use crate::json_lines::JsonLines; @@ -43,5 +47,3 @@ pub use crate::json_lines::JsonLines; #[cfg(feature = "typed-header")] #[doc(no_inline)] pub use crate::typed_header::TypedHeader; - -pub use crate::user_lang::UserLanguage; diff --git a/axum-extra/src/user_lang/config.rs b/axum-extra/src/extract/user_lang/config.rs similarity index 94% rename from axum-extra/src/user_lang/config.rs rename to axum-extra/src/extract/user_lang/config.rs index ade0484a43..81bf387b72 100644 --- a/axum-extra/src/user_lang/config.rs +++ b/axum-extra/src/extract/user_lang/config.rs @@ -1,6 +1,7 @@ -use crate::user_lang::{UserLanguage, UserLanguageSource}; use std::sync::Arc; +use crate::extract::user_lang::{UserLanguage, UserLanguageSource}; + /// TBD #[derive(Debug, Clone)] pub struct UserLanguageConfig { diff --git a/axum-extra/src/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs similarity index 77% rename from axum-extra/src/user_lang/lang.rs rename to axum-extra/src/extract/user_lang/lang.rs index aeaeb3ad84..bdc7eab567 100644 --- a/axum-extra/src/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -9,7 +9,29 @@ use std::{ sync::{Arc, OnceLock}, }; -/// TBD +/// The users preferred languages, read from the request. +/// +/// This extractor reads the users preferred languages from a +/// configurable list of sources. +/// +/// By default it will try to read from the following sources: +/// * The query parameter `lang` +/// * The path segment `:lang` +/// * The `Accept-Language` header +/// +/// # Configuration +/// +/// To configure the sources see [`UserLanguage::config`] for details. +/// +/// # Example +/// +/// ```rust +/// use axum_extra::extract::UserLanguage; +/// +/// async fn handler(UserLanguage(lang): UserLanguage) { +/// println!("Preferred languages: {:?}", lang.preferred_languages()); +/// } +/// ``` #[derive(Debug, Clone)] pub struct UserLanguage { preferred_languages: Vec, diff --git a/axum-extra/src/user_lang/mod.rs b/axum-extra/src/extract/user_lang/mod.rs similarity index 81% rename from axum-extra/src/user_lang/mod.rs rename to axum-extra/src/extract/user_lang/mod.rs index dfe04427e2..b1876f6d46 100644 --- a/axum-extra/src/user_lang/mod.rs +++ b/axum-extra/src/extract/user_lang/mod.rs @@ -1,4 +1,4 @@ -//! TBD +//! User language extractor. mod config; mod lang; diff --git a/axum-extra/src/user_lang/source.rs b/axum-extra/src/extract/user_lang/source.rs similarity index 100% rename from axum-extra/src/user_lang/source.rs rename to axum-extra/src/extract/user_lang/source.rs diff --git a/axum-extra/src/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs similarity index 96% rename from axum-extra/src/user_lang/sources/header.rs rename to axum-extra/src/extract/user_lang/sources/header.rs index e6c7492039..65dfe4a71d 100644 --- a/axum-extra/src/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -1,7 +1,8 @@ -use crate::user_lang::UserLanguageSource; use axum::async_trait; use std::cmp::Ordering; +use crate::extract::user_lang::UserLanguageSource; + /// TBD #[derive(Debug, Clone)] pub struct AcceptLanguageSource; diff --git a/axum-extra/src/user_lang/sources/mod.rs b/axum-extra/src/extract/user_lang/sources/mod.rs similarity index 100% rename from axum-extra/src/user_lang/sources/mod.rs rename to axum-extra/src/extract/user_lang/sources/mod.rs diff --git a/axum-extra/src/user_lang/sources/path.rs b/axum-extra/src/extract/user_lang/sources/path.rs similarity index 93% rename from axum-extra/src/user_lang/sources/path.rs rename to axum-extra/src/extract/user_lang/sources/path.rs index 1a80064260..45a042ffc3 100644 --- a/axum-extra/src/user_lang/sources/path.rs +++ b/axum-extra/src/extract/user_lang/sources/path.rs @@ -1,7 +1,8 @@ -use crate::user_lang::UserLanguageSource; use axum::{async_trait, extract::Path, RequestPartsExt}; use std::collections::HashMap; +use crate::extract::user_lang::UserLanguageSource; + /// TBD #[derive(Debug, Clone)] pub struct PathSource { diff --git a/axum-extra/src/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs similarity index 93% rename from axum-extra/src/user_lang/sources/query.rs rename to axum-extra/src/extract/user_lang/sources/query.rs index 5588454334..50d0b0efc4 100644 --- a/axum-extra/src/user_lang/sources/query.rs +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -1,7 +1,8 @@ -use crate::user_lang::UserLanguageSource; use axum::{async_trait, extract::Query, RequestPartsExt}; use std::collections::HashMap; +use crate::extract::user_lang::UserLanguageSource; + /// TBD #[derive(Debug, Clone)] pub struct QuerySource { diff --git a/axum-extra/src/lib.rs b/axum-extra/src/lib.rs index 0f11110743..e6f9e87981 100644 --- a/axum-extra/src/lib.rs +++ b/axum-extra/src/lib.rs @@ -77,7 +77,6 @@ pub mod handler; pub mod middleware; pub mod response; pub mod routing; -pub mod user_lang; #[cfg(feature = "json-lines")] pub mod json_lines; diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs index 58793aedba..afcb96afb4 100644 --- a/examples/user-language/src/main.rs +++ b/examples/user-language/src/main.rs @@ -12,13 +12,18 @@ use axum_extra::{ #[tokio::main] async fn main() { - // build our application with a route + // build our application with some routes let app = Router::new() .route("/", get(handler)) .route("/:lang", get(handler)) + // Add configuration for the `UserLanguage` extractor. + // This step is optional, if omitted the default + // configuration will be used. .layer(Extension( UserLanguage::config() + // read the language from the `lang` query parameter .add_source(QuerySource::new("lang")) + // read the language from the `:lang` segment of the path .add_source(PathSource::new("lang")) .build(), )); From 4e75f789d1c679f09d8314384c1f16d5a62d3f90 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Thu, 28 Sep 2023 19:12:17 +0000 Subject: [PATCH 08/24] Add documentation to the lang module --- axum-extra/src/extract/user_lang/lang.rs | 38 ++++++++++++++++++------ axum-extra/src/extract/user_lang/mod.rs | 2 +- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index bdc7eab567..a84c22e72e 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -15,20 +15,22 @@ use std::{ /// configurable list of sources. /// /// By default it will try to read from the following sources: -/// * The query parameter `lang` -/// * The path segment `:lang` -/// * The `Accept-Language` header +/// * The query parameter `lang` +/// * The path segment `:lang` +/// * The `Accept-Language` header /// /// # Configuration /// -/// To configure the sources see [`UserLanguage::config`] for details. +/// To configure the sources for the languages see [`UserLanguage::config`]. +/// You can also create a custom source. See [`UserLanguageSource`] on how to +/// implement one. /// /// # Example /// /// ```rust /// use axum_extra::extract::UserLanguage; /// -/// async fn handler(UserLanguage(lang): UserLanguage) { +/// async fn handler(lang: UserLanguage) { /// println!("Preferred languages: {:?}", lang.preferred_languages()); /// } /// ``` @@ -39,7 +41,13 @@ pub struct UserLanguage { } impl UserLanguage { - /// TBD + /// The default sources for the preferred languages. + /// + /// If you do not add a configuration for the [`UserLanguage`] extractor, + /// these sources will be used by default. They are in order: + /// * The query parameter `lang` + /// * The path segment `:lang` + /// * The `Accept-Language` header pub fn default_sources() -> &'static Vec> { static DEFAULT_SOURCES: OnceLock>> = OnceLock::new(); @@ -52,19 +60,31 @@ impl UserLanguage { }) } - /// TBD + /// The users most preferred language as read from the request. + /// + /// This is the first language in the list of [`preferred_languages`]. + /// If no language could be read from the request, the fallback language + /// will be returned. pub fn preferred_language(&self) -> &str { self.preferred_languages .first() .unwrap_or(&self.fallback_language) } - /// TBD + /// The users preferred languages in order of preference. + /// + /// Preference is first determined by the order of the sources. + /// Within each source the languages are ordered by the users preference, + /// if applicable for the source. For example the `Accept-Language` header + /// source will order the languages by the `q` parameter. + /// + /// This list may be empty if no language could be read from the request. pub fn preferred_languages(&self) -> &[String] { self.preferred_languages.as_slice() } - /// TBD + /// The language that will be used as a fallback if no language could be + /// read from the request. pub fn fallback_language(&self) -> &str { &self.fallback_language } diff --git a/axum-extra/src/extract/user_lang/mod.rs b/axum-extra/src/extract/user_lang/mod.rs index b1876f6d46..cd03355ca4 100644 --- a/axum-extra/src/extract/user_lang/mod.rs +++ b/axum-extra/src/extract/user_lang/mod.rs @@ -1,4 +1,4 @@ -//! User language extractor. +//! Extractor that retrieves the preferred languages of the user. mod config; mod lang; From d659285954251a3b00c044c3da69af72767688dc Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Thu, 28 Sep 2023 19:40:11 +0000 Subject: [PATCH 09/24] Add documentation for the config module --- axum-extra/src/extract/user_lang/config.rs | 66 ++++++++++++++++++---- axum-extra/src/extract/user_lang/lang.rs | 3 + examples/user-language/src/main.rs | 5 +- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/axum-extra/src/extract/user_lang/config.rs b/axum-extra/src/extract/user_lang/config.rs index 81bf387b72..0c8e00837b 100644 --- a/axum-extra/src/extract/user_lang/config.rs +++ b/axum-extra/src/extract/user_lang/config.rs @@ -2,17 +2,61 @@ use std::sync::Arc; use crate::extract::user_lang::{UserLanguage, UserLanguageSource}; -/// TBD +/// Configuration for the [`UserLanguage`] extractor. +/// +/// By default the [`UserLanguage`] extractor will try to read the +/// languages from the sources returned by [`UserLanguage::default_sources`]. +/// +/// You can override the default behaviour by adding a [`UserLanguageConfig`] +/// extension to your routes. +/// +/// You can add sources and specify a fallback language. +/// +/// # Example +/// +/// ```rust +/// use axum::{routing::get, Extension, Router}; +/// use axum_extra::extract::user_lang::{PathSource, QuerySource, UserLanguage}; +/// +/// # fn main() { +/// let app = Router::new() +/// .route("/:lang", get(handler)) +/// .layer(Extension( +/// UserLanguage::config() +/// .add_source(QuerySource::new("lang")) +/// .add_source(PathSource::new("lang")) +/// .build(), +/// )); +/// # let _: Router = app; +/// # } +/// # async fn handler() {} +/// ``` +/// #[derive(Debug, Clone)] pub struct UserLanguageConfig { - /// TBD - pub fallback_language: String, - - /// TBD - pub sources: Vec>, + pub(crate) fallback_language: String, + pub(crate) sources: Vec>, } -/// TBD +/// Builder for [`UserLanguageConfig`]. +/// +/// Allows you to declaratively create a [`UserLanguageConfig`]. +/// You can create a [`UserLanguageConfigBuilder`] by calling +/// [`UserLanguage::config`]. +/// +/// # Example +/// +/// ```rust +/// use axum_extra::extract::user_lang::{QuerySource, UserLanguage}; +/// +/// # fn main() { +/// let config = UserLanguage::config() +/// .add_source(QuerySource::new("lang")) +/// .fallback_language("es") +/// .build(); +/// # let _ = config; +/// # } +/// ``` #[derive(Debug, Clone)] pub struct UserLanguageConfigBuilder { fallback_language: String, @@ -20,19 +64,19 @@ pub struct UserLanguageConfigBuilder { } impl UserLanguageConfigBuilder { - /// TBD + /// Set the fallback language. pub fn fallback_language(mut self, fallback_language: impl Into) -> Self { self.fallback_language = fallback_language.into(); self } - /// TBD + /// Add a [`UserLanguageSource`]. pub fn add_source(mut self, source: impl UserLanguageSource + 'static) -> Self { self.sources.push(Arc::new(source)); self } - /// TBD + /// Create a [`UserLanguageConfig`] from this builder. pub fn build(self) -> UserLanguageConfig { UserLanguageConfig { fallback_language: self.fallback_language, @@ -46,7 +90,7 @@ impl UserLanguageConfigBuilder { } impl UserLanguage { - /// TBD + /// Returns a builder for [`UserLanguageConfig`]. pub fn config() -> UserLanguageConfigBuilder { UserLanguageConfigBuilder { fallback_language: "en".to_string(), diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index a84c22e72e..d8dd1b1735 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -19,6 +19,9 @@ use std::{ /// * The path segment `:lang` /// * The `Accept-Language` header /// +/// If no language could be read from the request, the fallback language +/// is "en". +/// /// # Configuration /// /// To configure the sources for the languages see [`UserLanguage::config`]. diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs index afcb96afb4..ff3962b1fa 100644 --- a/examples/user-language/src/main.rs +++ b/examples/user-language/src/main.rs @@ -5,10 +5,7 @@ //! ``` use axum::{response::Html, routing::get, Extension, Router}; -use axum_extra::{ - extract::UserLanguage, - user_lang::{PathSource, QuerySource}, -}; +use axum_extra::extract::user_lang::{PathSource, QuerySource, UserLanguage}; #[tokio::main] async fn main() { From 2ccc8ac5bd7ca9c01e06097976d214167f91e6c7 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Thu, 28 Sep 2023 19:43:57 +0000 Subject: [PATCH 10/24] Rename UserLanguageConfig/Builder --- axum-extra/src/extract/user_lang/config.rs | 26 +++++++++++----------- axum-extra/src/extract/user_lang/lang.rs | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/axum-extra/src/extract/user_lang/config.rs b/axum-extra/src/extract/user_lang/config.rs index 0c8e00837b..91a81db275 100644 --- a/axum-extra/src/extract/user_lang/config.rs +++ b/axum-extra/src/extract/user_lang/config.rs @@ -7,7 +7,7 @@ use crate::extract::user_lang::{UserLanguage, UserLanguageSource}; /// By default the [`UserLanguage`] extractor will try to read the /// languages from the sources returned by [`UserLanguage::default_sources`]. /// -/// You can override the default behaviour by adding a [`UserLanguageConfig`] +/// You can override the default behaviour by adding a [`Config`] /// extension to your routes. /// /// You can add sources and specify a fallback language. @@ -33,15 +33,15 @@ use crate::extract::user_lang::{UserLanguage, UserLanguageSource}; /// ``` /// #[derive(Debug, Clone)] -pub struct UserLanguageConfig { +pub struct Config { pub(crate) fallback_language: String, pub(crate) sources: Vec>, } -/// Builder for [`UserLanguageConfig`]. +/// Builder to create a [`Config`] for the [`UserLanguage`] extractor. /// -/// Allows you to declaratively create a [`UserLanguageConfig`]. -/// You can create a [`UserLanguageConfigBuilder`] by calling +/// Allows you to declaratively create a [`Config`]. +/// You can create a [`ConfigBuilder`] by calling /// [`UserLanguage::config`]. /// /// # Example @@ -58,12 +58,12 @@ pub struct UserLanguageConfig { /// # } /// ``` #[derive(Debug, Clone)] -pub struct UserLanguageConfigBuilder { +pub struct ConfigBuilder { fallback_language: String, sources: Vec>, } -impl UserLanguageConfigBuilder { +impl ConfigBuilder { /// Set the fallback language. pub fn fallback_language(mut self, fallback_language: impl Into) -> Self { self.fallback_language = fallback_language.into(); @@ -76,9 +76,9 @@ impl UserLanguageConfigBuilder { self } - /// Create a [`UserLanguageConfig`] from this builder. - pub fn build(self) -> UserLanguageConfig { - UserLanguageConfig { + /// Create a [`Config`] from this builder. + pub fn build(self) -> Config { + Config { fallback_language: self.fallback_language, sources: if !self.sources.is_empty() { self.sources @@ -90,9 +90,9 @@ impl UserLanguageConfigBuilder { } impl UserLanguage { - /// Returns a builder for [`UserLanguageConfig`]. - pub fn config() -> UserLanguageConfigBuilder { - UserLanguageConfigBuilder { + /// Returns a builder for [`Config`]. + pub fn config() -> ConfigBuilder { + ConfigBuilder { fallback_language: "en".to_string(), sources: vec![], } diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index d8dd1b1735..a23d58edb5 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -1,6 +1,6 @@ use super::{ sources::{AcceptLanguageSource, PathSource, QuerySource}, - UserLanguageConfig, UserLanguageSource, + Config, UserLanguageSource, }; use axum::{async_trait, extract::FromRequestParts, Extension, RequestPartsExt}; use http::request::Parts; @@ -65,7 +65,7 @@ impl UserLanguage { /// The users most preferred language as read from the request. /// - /// This is the first language in the list of [`preferred_languages`]. + /// This is the first language in the list of [`UserLanguage::preferred_languages`]. /// If no language could be read from the request, the fallback language /// will be returned. pub fn preferred_language(&self) -> &str { @@ -102,7 +102,7 @@ where async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let (sources, fallback_language) = - match parts.extract::>().await { + match parts.extract::>().await { Ok(Extension(config)) => (Some(config.sources), Some(config.fallback_language)), Err(_) => (None, None), }; From 02b1f96f7bfdb345e2980e2725af303eb188551e Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Thu, 28 Sep 2023 19:55:17 +0000 Subject: [PATCH 11/24] Add docs for UserLanguageSource --- axum-extra/src/extract/user_lang/source.rs | 38 ++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/axum-extra/src/extract/user_lang/source.rs b/axum-extra/src/extract/user_lang/source.rs index 32594fb9ca..2debd55cb2 100644 --- a/axum-extra/src/extract/user_lang/source.rs +++ b/axum-extra/src/extract/user_lang/source.rs @@ -2,9 +2,43 @@ use axum::async_trait; use http::request::Parts; use std::fmt::Debug; -/// TBD +/// A source for the users preferred languages. +/// +/// # Implementing a custom source +/// +/// The following is an example of how to read the language from the query. +/// +/// ```rust +/// use std::collections::HashMap; +/// use axum::{extract::Query, RequestPartsExt}; +/// use axum_extra::extract::user_lang::UserLanguageSource; +/// +/// #[derive(Debug)] +/// pub struct QuerySource; +/// +/// #[axum::async_trait] +/// impl UserLanguageSource for QuerySource { +/// async fn languages_from_parts(&self, parts: &mut http::request::Parts) -> Vec { +/// let Ok(query) = parts.extract::>>().await else { +/// return vec![]; +/// }; +/// +/// let Some(lang) = query.get("lang") else { +/// return vec![]; +/// }; +/// +/// vec![lang.to_string()] +/// } +/// } +/// ``` #[async_trait] pub trait UserLanguageSource: Send + Sync + Debug { - /// TBD + /// Extract a list of user languages from the request parts. + /// + /// The multiple languages are returned, they should be in + /// order of preference of the user, if possible. + /// + /// If no languages could be read from the request, return + /// an empty vec. async fn languages_from_parts(&self, parts: &mut Parts) -> Vec; } From b640fa884ebcd0ad7d70dcc4545837e2496009b6 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Thu, 28 Sep 2023 20:16:56 +0000 Subject: [PATCH 12/24] Add docs for PathSource --- axum-extra/src/extract/user_lang/lang.rs | 14 ++++--- .../src/extract/user_lang/sources/path.rs | 37 +++++++++++++++++-- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index a23d58edb5..bf64764b8b 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -19,14 +19,18 @@ use std::{ /// * The path segment `:lang` /// * The `Accept-Language` header /// -/// If no language could be read from the request, the fallback language -/// is "en". +/// This extractor never fails. If no language could be read from the request, +/// the fallback language will be used. By default the fallback is `en`, but +/// this can be configured. /// /// # Configuration /// -/// To configure the sources for the languages see [`UserLanguage::config`]. -/// You can also create a custom source. See [`UserLanguageSource`] on how to -/// implement one. +/// To configure the sources for the languages or the fallback language, see [`UserLanguage::config`]. +/// +/// # Custom Sources +/// +/// You can create custom user langauge sources. See +/// [`UserLanguageSource`] for details. /// /// # Example /// diff --git a/axum-extra/src/extract/user_lang/sources/path.rs b/axum-extra/src/extract/user_lang/sources/path.rs index 45a042ffc3..c46399d4cc 100644 --- a/axum-extra/src/extract/user_lang/sources/path.rs +++ b/axum-extra/src/extract/user_lang/sources/path.rs @@ -3,15 +3,46 @@ use std::collections::HashMap; use crate::extract::user_lang::UserLanguageSource; -/// TBD +/// A source that reads the user language from the request path. +/// +/// When creating this source you specify the name of the path +/// segment to read the language from. The routes you want to extract +/// the language from must include a path segment with the configured +/// name for this source to be able to read the language. +/// +/// # Example +/// +/// The following example will read the language from +/// the path segment `lang_id`. Your routes need to include +/// a `:lang_id` path segment that will contain the language. +/// +/// ```rust +/// # use axum::{Router, extract::Extension, routing::get}; +/// # use axum_extra::extract::user_lang::{UserLanguage, PathSource}; +/// # +/// // The path segment name is `lang_id`. +/// let source = PathSource::new("lang_id"); +/// +/// // The routes need to include a `:lang_id` path segment. +/// let app = Router::new() +/// .route("/home/:lang_id", get(handler)) +/// .layer( +/// Extension( +/// UserLanguage::config() +/// .add_source(source) +/// .build(), +/// )); +/// +/// # let _: Router = app; +/// # async fn handler() {} +/// ``` #[derive(Debug, Clone)] pub struct PathSource { - /// TBD name: String, } impl PathSource { - /// TBD + /// Create a new path source with a given path segment name. pub fn new(name: impl Into) -> Self { Self { name: name.into() } } From 0004c64a5aa290e1af2ac164709c1411eb5a727e Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Fri, 29 Sep 2023 06:49:52 +0000 Subject: [PATCH 13/24] Add docs for query and accept header sources --- .../src/extract/user_lang/sources/header.rs | 27 +++++++++++++- .../src/extract/user_lang/sources/query.rs | 35 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs index 65dfe4a71d..cb738c7216 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -3,7 +3,31 @@ use std::cmp::Ordering; use crate::extract::user_lang::UserLanguageSource; -/// TBD +/// A [`UserLanguageSource`] that reads languages from the `Accept-Language` header. +/// +/// This source may return multiple languages. Languages are returned in order of their +/// quality values. +/// +/// # Example +/// +/// ```rust +/// # use axum::{Router, extract::Extension, routing::get}; +/// # use axum_extra::extract::user_lang::{UserLanguage, AcceptLanguageSource}; +/// # +/// let source = AcceptLanguageSource; +/// +/// let app = Router::new() +/// .route("/home", get(handler)) +/// .layer( +/// Extension( +/// UserLanguage::config() +/// .add_source(source) +/// .build(), +/// )); +/// +/// # let _: Router = app; +/// # async fn handler() {} +/// ``` #[derive(Debug, Clone)] pub struct AcceptLanguageSource; @@ -25,6 +49,7 @@ impl UserLanguageSource for AcceptLanguageSource { } } +/// Parse quality values from the `Accept-Language` header. fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { let mut values = values.split(','); let mut quality_values = Vec::new(); diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs index 50d0b0efc4..9db20f0810 100644 --- a/axum-extra/src/extract/user_lang/sources/query.rs +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -3,15 +3,44 @@ use std::collections::HashMap; use crate::extract::user_lang::UserLanguageSource; -/// TBD +/// A [`UserLanguageSource`] that reads the language from a field in the +/// query string. +/// +/// When creating this source you specify the name of the query +/// field to read the language from. You can add multiple `QuerySource` +/// instances to read from different fields. +/// +/// # Example +/// +/// The following example will read the language from +/// the query field `lang_id`. +/// +/// ```rust +/// # use axum::{Router, extract::Extension, routing::get}; +/// # use axum_extra::extract::user_lang::{UserLanguage, QuerySource}; +/// # +/// // The query field name is `lang_id`. +/// let source = QuerySource::new("lang_id"); +/// +/// let app = Router::new() +/// .route("/home", get(handler)) +/// .layer( +/// Extension( +/// UserLanguage::config() +/// .add_source(source) +/// .build(), +/// )); +/// +/// # let _: Router = app; +/// # async fn handler() {} +/// ``` #[derive(Debug, Clone)] pub struct QuerySource { - /// TBD name: String, } impl QuerySource { - /// TBD + /// Create a new query source with a given query field name. pub fn new(name: impl Into) -> Self { Self { name: name.into() } } From 0d68fff9dff1d0c228f9fb6cc9b8ffe03a062d9a Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Fri, 29 Sep 2023 08:10:13 +0000 Subject: [PATCH 14/24] Add some tests for user language extractor --- axum-extra/src/extract/user_lang/lang.rs | 120 +++++++++++++++++++---- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index bf64764b8b..91433b3608 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -13,27 +13,27 @@ use std::{ /// /// This extractor reads the users preferred languages from a /// configurable list of sources. -/// +/// /// By default it will try to read from the following sources: /// * The query parameter `lang` /// * The path segment `:lang` /// * The `Accept-Language` header -/// +/// /// This extractor never fails. If no language could be read from the request, /// the fallback language will be used. By default the fallback is `en`, but /// this can be configured. -/// +/// /// # Configuration -/// +/// /// To configure the sources for the languages or the fallback language, see [`UserLanguage::config`]. -/// +/// /// # Custom Sources -/// +/// /// You can create custom user langauge sources. See /// [`UserLanguageSource`] for details. -/// +/// /// # Example -/// +/// /// ```rust /// use axum_extra::extract::UserLanguage; /// @@ -49,7 +49,7 @@ pub struct UserLanguage { impl UserLanguage { /// The default sources for the preferred languages. - /// + /// /// If you do not add a configuration for the [`UserLanguage`] extractor, /// these sources will be used by default. They are in order: /// * The query parameter `lang` @@ -68,7 +68,7 @@ impl UserLanguage { } /// The users most preferred language as read from the request. - /// + /// /// This is the first language in the list of [`UserLanguage::preferred_languages`]. /// If no language could be read from the request, the fallback language /// will be returned. @@ -79,12 +79,12 @@ impl UserLanguage { } /// The users preferred languages in order of preference. - /// + /// /// Preference is first determined by the order of the sources. /// Within each source the languages are ordered by the users preference, /// if applicable for the source. For example the `Accept-Language` header /// source will order the languages by the `q` parameter. - /// + /// /// This list may be empty if no language could be read from the request. pub fn preferred_languages(&self) -> &[String] { self.preferred_languages.as_slice() @@ -105,11 +105,10 @@ where type Rejection = Infallible; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let (sources, fallback_language) = - match parts.extract::>().await { - Ok(Extension(config)) => (Some(config.sources), Some(config.fallback_language)), - Err(_) => (None, None), - }; + let (sources, fallback_language) = match parts.extract::>().await { + Ok(Extension(config)) => (Some(config.sources), Some(config.fallback_language)), + Err(_) => (None, None), + }; let sources = sources.as_ref().unwrap_or(Self::default_sources()); let fallback_language = fallback_language.unwrap_or_else(|| "en".to_string()); @@ -127,3 +126,90 @@ where }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::*; + use axum::{routing::get, Router}; + use http::{header::ACCEPT_LANGUAGE, StatusCode}; + + #[derive(Debug)] + struct TestSource(Vec); + + #[async_trait] + impl UserLanguageSource for TestSource { + async fn languages_from_parts(&self, _parts: &mut Parts) -> Vec { + self.0.clone() + } + } + + #[tokio::test] + async fn reads_from_configured_sources_in_specified_order() { + let app = Router::new() + .route("/", get(return_all_langs)) + .layer(Extension( + UserLanguage::config() + .add_source(TestSource(vec!["s1.1".to_string(), "s1.2".to_string()])) + .add_source(TestSource(vec!["s2.1".to_string(), "s2.2".to_string()])) + .build(), + )); + + let client = TestClient::new(app); + + let res = client.get("/").send().await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "s1.1,s1.2,s2.1,s2.2"); + } + + #[tokio::test] + async fn reads_languages_from_default_sources() { + let app = Router::new().route("/:lang", get(return_all_langs)); + + let client = TestClient::new(app); + + let res = client + .get("/de?lang=fr") + .header(ACCEPT_LANGUAGE, "en;q=0.9,es;q=0.8") + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "fr,de,en,es"); + } + + #[tokio::test] + async fn falls_back_to_configured_language() { + let app = Router::new().route("/", get(return_lang)).layer(Extension( + UserLanguage::config().fallback_language("fallback").build(), + )); + + let client = TestClient::new(app); + + let res = client.get("/").send().await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "fallback"); + } + + #[tokio::test] + async fn falls_back_to_default_language() { + let app = Router::new().route("/", get(return_lang)); + + let client = TestClient::new(app); + + let res = client.get("/").send().await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "en"); + } + + async fn return_lang(lang: UserLanguage) -> String { + lang.preferred_language().to_owned() + } + + async fn return_all_langs(lang: UserLanguage) -> String { + lang.preferred_languages().join(",") + } +} From a89879af736a4aba50103f054f1e5516b4e72d52 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Fri, 29 Sep 2023 08:45:42 +0000 Subject: [PATCH 15/24] Add tests for query and path source --- .../src/extract/user_lang/sources/path.rs | 36 ++++++++++++++++--- .../src/extract/user_lang/sources/query.rs | 22 ++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/axum-extra/src/extract/user_lang/sources/path.rs b/axum-extra/src/extract/user_lang/sources/path.rs index c46399d4cc..5dff56b800 100644 --- a/axum-extra/src/extract/user_lang/sources/path.rs +++ b/axum-extra/src/extract/user_lang/sources/path.rs @@ -46,6 +46,14 @@ impl PathSource { pub fn new(name: impl Into) -> Self { Self { name: name.into() } } + + fn languages_from_path(&self, path: Path>) -> Vec { + let Some(lang) = path.get(self.name.as_str()) else { + return vec![]; + }; + + vec![lang.to_string()] + } } #[async_trait] @@ -55,10 +63,30 @@ impl UserLanguageSource for PathSource { return vec![]; }; - let Some(lang) = path.get(self.name.as_str()) else { - return vec![]; - }; + self.languages_from_path(path) + } +} - vec![lang.to_string()] +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn reads_language_from_path() { + let source = PathSource::new("lang"); + + // We cannot setup the Path extractor here, as it requires + // UrlParams in the request extensions, which is private to axum. + // + // Instead we test loading from the extracted path directly. + let path = Path({ + let mut path_matches = HashMap::new(); + path_matches.insert("lang".to_string(), "it".to_string()); + path_matches + }); + + let languages = source.languages_from_path(path); + + assert_eq!(languages, vec!["it".to_string()]); } } diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs index 9db20f0810..4cdce33340 100644 --- a/axum-extra/src/extract/user_lang/sources/query.rs +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -60,3 +60,25 @@ impl UserLanguageSource for QuerySource { vec![lang.to_string()] } } + +#[cfg(test)] +mod tests { + use super::*; + use http::{Request, Uri}; + + #[tokio::test] + async fn reads_language_from_query() { + let source = QuerySource::new("lang"); + + let request: Request<()> = Request::builder() + .uri(Uri::builder().path_and_query("/?lang=de").build().unwrap()) + .body(()) + .unwrap(); + + let (mut parts, _) = request.into_parts(); + + let languages = source.languages_from_parts(&mut parts).await; + + assert_eq!(languages, vec!["de".to_string()]); + } +} From a1fe1fd598ec3aa2fd0da346beebd80b0d4dd36d Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Fri, 29 Sep 2023 09:05:08 +0000 Subject: [PATCH 16/24] Add test for header source --- .../src/extract/user_lang/sources/header.rs | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs index cb738c7216..30c1c1a04d 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -4,12 +4,12 @@ use std::cmp::Ordering; use crate::extract::user_lang::UserLanguageSource; /// A [`UserLanguageSource`] that reads languages from the `Accept-Language` header. -/// +/// /// This source may return multiple languages. Languages are returned in order of their /// quality values. -/// +/// /// # Example -/// +/// /// ```rust /// # use axum::{Router, extract::Extension, routing::get}; /// # use axum_extra::extract::user_lang::{UserLanguage, AcceptLanguageSource}; @@ -63,6 +63,11 @@ fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { continue; }; + if value.is_empty() { + // empty quality value entry + continue; + } + let quality = if let Some(quality) = quality.and_then(|q| q.strip_prefix("q=")) { quality.parse::().unwrap_or(0.0) } else { @@ -75,3 +80,55 @@ fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { quality_values.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); quality_values } + +#[cfg(test)] +mod tests { + use super::*; + use http::{header::ACCEPT_LANGUAGE, Request}; + + #[tokio::test] + async fn reads_language_from_accept_header() { + let source = AcceptLanguageSource; + + let request: Request<()> = Request::builder() + .header(ACCEPT_LANGUAGE, "fr,de;q=0.8,en;q=0.9") + .body(()) + .unwrap(); + + let (mut parts, _) = request.into_parts(); + + let languages = source.languages_from_parts(&mut parts).await; + + assert_eq!( + languages, + vec!["fr".to_string(), "en".to_string(), "de".to_string()] + ); + } + + #[test] + fn parsing_quality_values() { + let values = "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5"; + + let parsed = parse_quality_values(values); + + assert_eq!( + parsed, + vec![ + ("fr-CH", 1.0), + ("fr", 0.9), + ("en", 0.8), + ("de", 0.7), + ("*", 0.5), + ] + ); + } + + #[test] + fn empty_quality_values() { + let values = ""; + + let parsed = parse_quality_values(values); + + assert_eq!(parsed, vec![]); + } +} From f247202e6855f1400e6ce3655c5c841b5199f9ef Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Fri, 29 Sep 2023 09:07:28 +0000 Subject: [PATCH 17/24] Remove unintentionally checked-in files --- .devcontainer/devcontainer.json | 34 --------------------------------- .vscode/settings.json | 4 ---- 2 files changed, 38 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .vscode/settings.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 60c7aa4105..0000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,34 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/rust -{ - "name": "Rust", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", - "features": { - "ghcr.io/devcontainers-contrib/features/fish-apt-get:1": {} - } - - // Use 'mounts' to make the cargo cache persistent in a Docker Volume. - // "mounts": [ - // { - // "source": "devcontainer-cargo-cache-${devcontainerId}", - // "target": "/usr/local/cargo", - // "type": "volume" - // } - // ] - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "rustc --version", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 4bc45ade3d..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "rust-analyzer.showUnlinkedFileNotification": false, - "rust-analyzer.cargo.features": "all" -} \ No newline at end of file From 49bcd596657b1604d969b309882025d210434ceb Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Fri, 29 Sep 2023 12:32:02 +0000 Subject: [PATCH 18/24] Ignore wildcard language in accept source --- .../src/extract/user_lang/sources/header.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs index 30c1c1a04d..4cc7c449f5 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -44,6 +44,7 @@ impl UserLanguageSource for AcceptLanguageSource { parse_quality_values(accept_language) .into_iter() + .filter(|(lang, _)| *lang != "*") .map(|(lang, _)| lang.to_string()) .collect() } @@ -105,6 +106,22 @@ mod tests { ); } + #[tokio::test] + async fn ignores_wildcard_lang() { + let source = AcceptLanguageSource; + + let request: Request<()> = Request::builder() + .header(ACCEPT_LANGUAGE, "fr,de;q=0.8,*;q=0.9") + .body(()) + .unwrap(); + + let (mut parts, _) = request.into_parts(); + + let languages = source.languages_from_parts(&mut parts).await; + + assert_eq!(languages, vec!["fr".to_string(), "de".to_string()]); + } + #[test] fn parsing_quality_values() { let values = "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5"; From cfed2b4f9b8de12068e7dfef9250e5d233ee2be1 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 7 Jan 2024 21:44:38 +0100 Subject: [PATCH 19/24] Fix CI issues --- axum-extra/src/extract/user_lang/lang.rs | 2 +- axum-extra/src/extract/user_lang/sources/mod.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index 91433b3608..679ff94640 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -29,7 +29,7 @@ use std::{ /// /// # Custom Sources /// -/// You can create custom user langauge sources. See +/// You can create custom user language sources. See /// [`UserLanguageSource`] for details. /// /// # Example diff --git a/axum-extra/src/extract/user_lang/sources/mod.rs b/axum-extra/src/extract/user_lang/sources/mod.rs index b166f9a738..33b9483be7 100644 --- a/axum-extra/src/extract/user_lang/sources/mod.rs +++ b/axum-extra/src/extract/user_lang/sources/mod.rs @@ -1,7 +1,11 @@ mod header; mod path; + +#[cfg(feature = "query")] mod query; pub use header::*; pub use path::*; + +#[cfg(feature = "query")] pub use query::*; From fc60aa60d8abbc79b991a94c6d7dbae759ff5b9b Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 7 Jan 2024 21:58:18 +0100 Subject: [PATCH 20/24] Replace usages of (&str).to_string() with to_owned() --- axum-extra/src/extract/user_lang/sources/header.rs | 6 +++--- axum-extra/src/extract/user_lang/sources/path.rs | 6 +++--- axum-extra/src/extract/user_lang/sources/query.rs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs index 4cc7c449f5..7d29b0074c 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -45,7 +45,7 @@ impl UserLanguageSource for AcceptLanguageSource { parse_quality_values(accept_language) .into_iter() .filter(|(lang, _)| *lang != "*") - .map(|(lang, _)| lang.to_string()) + .map(|(lang, _)| lang.to_owned()) .collect() } } @@ -102,7 +102,7 @@ mod tests { assert_eq!( languages, - vec!["fr".to_string(), "en".to_string(), "de".to_string()] + vec!["fr".to_owned(), "en".to_owned(), "de".to_owned()] ); } @@ -119,7 +119,7 @@ mod tests { let languages = source.languages_from_parts(&mut parts).await; - assert_eq!(languages, vec!["fr".to_string(), "de".to_string()]); + assert_eq!(languages, vec!["fr".to_owned(), "de".to_owned()]); } #[test] diff --git a/axum-extra/src/extract/user_lang/sources/path.rs b/axum-extra/src/extract/user_lang/sources/path.rs index 5dff56b800..996412f202 100644 --- a/axum-extra/src/extract/user_lang/sources/path.rs +++ b/axum-extra/src/extract/user_lang/sources/path.rs @@ -52,7 +52,7 @@ impl PathSource { return vec![]; }; - vec![lang.to_string()] + vec![lang.to_owned()] } } @@ -81,12 +81,12 @@ mod tests { // Instead we test loading from the extracted path directly. let path = Path({ let mut path_matches = HashMap::new(); - path_matches.insert("lang".to_string(), "it".to_string()); + path_matches.insert("lang".to_owned(), "it".to_owned()); path_matches }); let languages = source.languages_from_path(path); - assert_eq!(languages, vec!["it".to_string()]); + assert_eq!(languages, vec!["it".to_owned()]); } } diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs index 4cdce33340..f9add43f6a 100644 --- a/axum-extra/src/extract/user_lang/sources/query.rs +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -57,7 +57,7 @@ impl UserLanguageSource for QuerySource { return vec![]; }; - vec![lang.to_string()] + vec![lang.to_owned()] } } @@ -79,6 +79,6 @@ mod tests { let languages = source.languages_from_parts(&mut parts).await; - assert_eq!(languages, vec!["de".to_string()]); + assert_eq!(languages, vec!["de".to_owned()]); } } From 604c25ec2c87d59aed8d0046c9b973be0a410cc9 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 7 Jan 2024 22:02:41 +0100 Subject: [PATCH 21/24] Use query source conditionally based on feature flag --- axum-extra/src/extract/user_lang/lang.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/axum-extra/src/extract/user_lang/lang.rs b/axum-extra/src/extract/user_lang/lang.rs index 679ff94640..06cf560bd7 100644 --- a/axum-extra/src/extract/user_lang/lang.rs +++ b/axum-extra/src/extract/user_lang/lang.rs @@ -1,5 +1,5 @@ use super::{ - sources::{AcceptLanguageSource, PathSource, QuerySource}, + sources::{AcceptLanguageSource, PathSource}, Config, UserLanguageSource, }; use axum::{async_trait, extract::FromRequestParts, Extension, RequestPartsExt}; @@ -9,6 +9,9 @@ use std::{ sync::{Arc, OnceLock}, }; +#[cfg(feature = "query")] +use super::sources::QuerySource; + /// The users preferred languages, read from the request. /// /// This extractor reads the users preferred languages from a @@ -52,7 +55,7 @@ impl UserLanguage { /// /// If you do not add a configuration for the [`UserLanguage`] extractor, /// these sources will be used by default. They are in order: - /// * The query parameter `lang` + /// * The query parameter `lang` (if feature `query` is enabled) /// * The path segment `:lang` /// * The `Accept-Language` header pub fn default_sources() -> &'static Vec> { @@ -60,6 +63,7 @@ impl UserLanguage { DEFAULT_SOURCES.get_or_init(|| { vec![ + #[cfg(feature = "query")] Arc::new(QuerySource::new("lang")), Arc::new(PathSource::new("lang")), Arc::new(AcceptLanguageSource), @@ -111,7 +115,7 @@ where }; let sources = sources.as_ref().unwrap_or(Self::default_sources()); - let fallback_language = fallback_language.unwrap_or_else(|| "en".to_string()); + let fallback_language = fallback_language.unwrap_or_else(|| "en".to_owned()); let mut preferred_languages = Vec::::new(); @@ -150,8 +154,8 @@ mod tests { .route("/", get(return_all_langs)) .layer(Extension( UserLanguage::config() - .add_source(TestSource(vec!["s1.1".to_string(), "s1.2".to_string()])) - .add_source(TestSource(vec!["s2.1".to_string(), "s2.2".to_string()])) + .add_source(TestSource(vec!["s1.1".to_owned(), "s1.2".to_owned()])) + .add_source(TestSource(vec!["s2.1".to_owned(), "s2.2".to_owned()])) .build(), )); From b51c6727444b6310b93bb4f4028034fc176d60a5 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 7 Jan 2024 22:06:26 +0100 Subject: [PATCH 22/24] Address more clippy issues --- axum-extra/src/extract/user_lang/config.rs | 2 +- axum-extra/src/extract/user_lang/source.rs | 2 +- axum-extra/src/extract/user_lang/sources/header.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/axum-extra/src/extract/user_lang/config.rs b/axum-extra/src/extract/user_lang/config.rs index 91a81db275..1c2861d1a6 100644 --- a/axum-extra/src/extract/user_lang/config.rs +++ b/axum-extra/src/extract/user_lang/config.rs @@ -93,7 +93,7 @@ impl UserLanguage { /// Returns a builder for [`Config`]. pub fn config() -> ConfigBuilder { ConfigBuilder { - fallback_language: "en".to_string(), + fallback_language: "en".to_owned(), sources: vec![], } } diff --git a/axum-extra/src/extract/user_lang/source.rs b/axum-extra/src/extract/user_lang/source.rs index 2debd55cb2..5071d238d3 100644 --- a/axum-extra/src/extract/user_lang/source.rs +++ b/axum-extra/src/extract/user_lang/source.rs @@ -27,7 +27,7 @@ use std::fmt::Debug; /// return vec![]; /// }; /// -/// vec![lang.to_string()] +/// vec![lang.to_owned()] /// } /// } /// ``` diff --git a/axum-extra/src/extract/user_lang/sources/header.rs b/axum-extra/src/extract/user_lang/sources/header.rs index 7d29b0074c..1dfe6df3de 100644 --- a/axum-extra/src/extract/user_lang/sources/header.rs +++ b/axum-extra/src/extract/user_lang/sources/header.rs @@ -52,10 +52,10 @@ impl UserLanguageSource for AcceptLanguageSource { /// Parse quality values from the `Accept-Language` header. fn parse_quality_values(values: &str) -> Vec<(&str, f32)> { - let mut values = values.split(','); + let values = values.split(','); let mut quality_values = Vec::new(); - while let Some(value) = values.next() { + for value in values { let mut value = value.trim().split(';'); let (value, quality) = (value.next(), value.next()); From c596e0df3e1e923ed973e0031e37e7b5883c7f16 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 7 Jan 2024 22:16:56 +0100 Subject: [PATCH 23/24] Use crate export insted of re-export --- axum-extra/src/extract/user_lang/sources/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/axum-extra/src/extract/user_lang/sources/query.rs b/axum-extra/src/extract/user_lang/sources/query.rs index f9add43f6a..31b3333256 100644 --- a/axum-extra/src/extract/user_lang/sources/query.rs +++ b/axum-extra/src/extract/user_lang/sources/query.rs @@ -1,7 +1,7 @@ -use axum::{async_trait, extract::Query, RequestPartsExt}; +use axum::{async_trait, RequestPartsExt}; use std::collections::HashMap; -use crate::extract::user_lang::UserLanguageSource; +use crate::extract::{Query, user_lang::UserLanguageSource}; /// A [`UserLanguageSource`] that reads the language from a field in the /// query string. From cdf9a72c50bf2c9dcd821713bc63b4c38d03bfc2 Mon Sep 17 00:00:00 2001 From: Markus Gasser Date: Sun, 7 Jan 2024 22:17:56 +0100 Subject: [PATCH 24/24] Fix clippy hint in example --- examples/user-language/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/user-language/src/main.rs b/examples/user-language/src/main.rs index ff3962b1fa..0f88a46d3b 100644 --- a/examples/user-language/src/main.rs +++ b/examples/user-language/src/main.rs @@ -43,6 +43,6 @@ async fn handler(lang: UserLanguage) -> Html<&'static str> { "de" => Html("

Hallo, Welt!

"), "es" => Html("

Hola, Mundo!

"), "fr" => Html("

Bonjour, le monde!

"), - "en" | _ => Html("

Hello, World!

"), + _ => Html("

Hello, World!

"), } }