Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add extractor for user language #2198

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4577e7e
Add api for UserLanguage
frenetisch-applaudierend Aug 27, 2023
2eb08a1
Add a first implementation to read user language
frenetisch-applaudierend Aug 27, 2023
ca79cfb
Extract user lang sources
frenetisch-applaudierend Aug 27, 2023
e2c538b
Add possibility to configure
frenetisch-applaudierend Aug 27, 2023
55232bd
Improve UserLanguage example a bit
frenetisch-applaudierend Aug 27, 2023
d4ce66b
Reorganize user_lang modules
frenetisch-applaudierend Aug 27, 2023
c40f4bf
Move modules to be more consistent with the rest
frenetisch-applaudierend Sep 28, 2023
4e75f78
Add documentation to the lang module
frenetisch-applaudierend Sep 28, 2023
d659285
Add documentation for the config module
frenetisch-applaudierend Sep 28, 2023
2ccc8ac
Rename UserLanguageConfig/Builder
frenetisch-applaudierend Sep 28, 2023
02b1f96
Add docs for UserLanguageSource
frenetisch-applaudierend Sep 28, 2023
b640fa8
Add docs for PathSource
frenetisch-applaudierend Sep 28, 2023
0004c64
Add docs for query and accept header sources
frenetisch-applaudierend Sep 29, 2023
0d68fff
Add some tests for user language extractor
frenetisch-applaudierend Sep 29, 2023
a89879a
Add tests for query and path source
frenetisch-applaudierend Sep 29, 2023
a1fe1fd
Add test for header source
frenetisch-applaudierend Sep 29, 2023
f247202
Remove unintentionally checked-in files
frenetisch-applaudierend Sep 29, 2023
49bcd59
Ignore wildcard language in accept source
frenetisch-applaudierend Sep 29, 2023
3cafa1d
Merge upstream
frenetisch-applaudierend Jan 7, 2024
cfed2b4
Fix CI issues
frenetisch-applaudierend Jan 7, 2024
fc60aa6
Replace usages of (&str).to_string() with to_owned()
frenetisch-applaudierend Jan 7, 2024
604c25e
Use query source conditionally based on feature flag
frenetisch-applaudierend Jan 7, 2024
b51c672
Address more clippy issues
frenetisch-applaudierend Jan 7, 2024
c596e0d
Use crate export insted of re-export
frenetisch-applaudierend Jan 7, 2024
cdf9a72
Fix clippy hint in example
frenetisch-applaudierend Jan 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion axum-extra/src/extract/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ mod query;
#[cfg(feature = "multipart")]
pub mod multipart;

pub use self::{cached::Cached, optional_path::OptionalPath, with_rejection::WithRejection};
pub mod user_lang;

pub use self::{
cached::Cached, optional_path::OptionalPath, user_lang::UserLanguage,
with_rejection::WithRejection,
};

#[cfg(feature = "cookie")]
pub use self::cookie::CookieJar;
Expand Down
100 changes: 100 additions & 0 deletions axum-extra/src/extract/user_lang/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use std::sync::Arc;

use crate::extract::user_lang::{UserLanguage, UserLanguageSource};

/// 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 [`Config`]
/// 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 Config {
pub(crate) fallback_language: String,
pub(crate) sources: Vec<Arc<dyn UserLanguageSource>>,
}

/// Builder to create a [`Config`] for the [`UserLanguage`] extractor.
///
/// Allows you to declaratively create a [`Config`].
/// You can create a [`ConfigBuilder`] 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 ConfigBuilder {
fallback_language: String,
sources: Vec<Arc<dyn UserLanguageSource>>,
}

impl ConfigBuilder {
/// Set the fallback language.
pub fn fallback_language(mut self, fallback_language: impl Into<String>) -> Self {
self.fallback_language = fallback_language.into();
self
}

/// Add a [`UserLanguageSource`].
pub fn add_source(mut self, source: impl UserLanguageSource + 'static) -> Self {
self.sources.push(Arc::new(source));
self
}

/// 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
} else {
UserLanguage::default_sources().clone()
},
}
}
}

impl UserLanguage {
/// Returns a builder for [`Config`].
pub fn config() -> ConfigBuilder {
ConfigBuilder {
fallback_language: "en".to_owned(),
sources: vec![],
}
}
}
219 changes: 219 additions & 0 deletions axum-extra/src/extract/user_lang/lang.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
use super::{
sources::{AcceptLanguageSource, PathSource},
Config, UserLanguageSource,
};
use axum::{async_trait, extract::FromRequestParts, Extension, RequestPartsExt};
use http::request::Parts;
use std::{
convert::Infallible,
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
/// 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 language sources. See
/// [`UserLanguageSource`] for details.
///
/// # Example
///
/// ```rust
/// use axum_extra::extract::UserLanguage;
///
/// async fn handler(lang: UserLanguage) {
/// println!("Preferred languages: {:?}", lang.preferred_languages());
/// }
/// ```
#[derive(Debug, Clone)]
pub struct UserLanguage {
preferred_languages: Vec<String>,
fallback_language: String,
}

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` (if feature `query` is enabled)
/// * The path segment `:lang`
/// * The `Accept-Language` header
pub fn default_sources() -> &'static Vec<Arc<dyn UserLanguageSource>> {
static DEFAULT_SOURCES: OnceLock<Vec<Arc<dyn UserLanguageSource>>> = OnceLock::new();

DEFAULT_SOURCES.get_or_init(|| {
vec![
#[cfg(feature = "query")]
Arc::new(QuerySource::new("lang")),
Arc::new(PathSource::new("lang")),
Arc::new(AcceptLanguageSource),
]
})
}

/// 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.
pub fn preferred_language(&self) -> &str {
self.preferred_languages
.first()
.unwrap_or(&self.fallback_language)
}

/// 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()
}

/// 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
}
}

#[async_trait]
impl<S> FromRequestParts<S> for UserLanguage
where
S: Send + Sync,
{
type Rejection = Infallible;

async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let (sources, fallback_language) = match parts.extract::<Extension<Config>>().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_owned());

let mut preferred_languages = Vec::<String>::new();

for source in sources {
let languages = source.languages_from_parts(parts).await;
preferred_languages.extend(languages);
}

Ok(UserLanguage {
preferred_languages,
fallback_language,
})
}
}

#[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<String>);

#[async_trait]
impl UserLanguageSource for TestSource {
async fn languages_from_parts(&self, _parts: &mut Parts) -> Vec<String> {
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_owned(), "s1.2".to_owned()]))
.add_source(TestSource(vec!["s2.1".to_owned(), "s2.2".to_owned()]))
.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(",")
}
}
11 changes: 11 additions & 0 deletions axum-extra/src/extract/user_lang/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! Extractor that retrieves the preferred languages of the user.

mod config;
mod lang;
mod source;
mod sources;

pub use config::*;
pub use lang::*;
pub use source::*;
pub use sources::*;
44 changes: 44 additions & 0 deletions axum-extra/src/extract/user_lang/source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use axum::async_trait;
use http::request::Parts;
use std::fmt::Debug;

/// 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<String> {
/// let Ok(query) = parts.extract::<Query<HashMap<String, String>>>().await else {
/// return vec![];
/// };
///
/// let Some(lang) = query.get("lang") else {
/// return vec![];
/// };
///
/// vec![lang.to_owned()]
/// }
/// }
/// ```
#[async_trait]
pub trait UserLanguageSource: Send + Sync + Debug {
/// 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<String>;
}
Loading
Loading