Skip to main content

baukit_http/
locale.rs

1use std::collections::HashSet;
2
3use axum::{
4    extract::{FromRef, FromRequestParts},
5    http::{header, request::Parts},
6    response::{IntoResponse, Response},
7};
8use thiserror::Error;
9
10use crate::ApiError;
11
12/// Maximum raw query length inspected by [`RequestLocale`].
13pub const MAX_LOCALE_QUERY_BYTES: usize = 2_048;
14/// Maximum combined `Accept-Language` header length inspected by [`RequestLocale`].
15pub const MAX_ACCEPT_LANGUAGE_BYTES: usize = 1_024;
16/// Maximum number of locales in a [`RequestLocaleConfig`].
17pub const MAX_SUPPORTED_LOCALES: usize = 64;
18
19const MAX_LOCALE_TAG_BYTES: usize = 64;
20const MAX_QUERY_PARAMETER_BYTES: usize = 32;
21
22/// Controls whether a decoded query parameter overrides `Accept-Language`.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub enum LocaleQueryOverride {
25    /// Ignore query parameters when selecting a locale.
26    Disabled,
27    /// Use the named query parameter before `Accept-Language`.
28    Parameter(String),
29}
30
31impl LocaleQueryOverride {
32    /// Creates an enabled query override after validating its parameter name.
33    pub fn parameter(name: impl Into<String>) -> Result<Self, RequestLocaleConfigError> {
34        let name = name.into();
35        if name.is_empty()
36            || name.len() > MAX_QUERY_PARAMETER_BYTES
37            || !name
38                .bytes()
39                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
40        {
41            return Err(RequestLocaleConfigError::InvalidQueryParameter);
42        }
43        Ok(Self::Parameter(name))
44    }
45}
46
47/// Invalid request-locale configuration.
48#[derive(Clone, Debug, Eq, Error, PartialEq)]
49pub enum RequestLocaleConfigError {
50    /// No supported locale was supplied.
51    #[error("at least one supported locale is required")]
52    EmptySupportedLocales,
53    /// More than [`MAX_SUPPORTED_LOCALES`] locales were supplied.
54    #[error("at most {MAX_SUPPORTED_LOCALES} supported locales are allowed")]
55    TooManySupportedLocales,
56    /// A supported locale was empty, oversized, duplicated, or malformed.
57    #[error("supported locale is empty, oversized, duplicated, or malformed")]
58    InvalidSupportedLocale,
59    /// The fallback was not an exact member of the supported set.
60    #[error("fallback locale must be supported")]
61    UnsupportedFallback,
62    /// The query parameter name was empty, oversized, or malformed.
63    #[error("locale query parameter name is empty, oversized, or malformed")]
64    InvalidQueryParameter,
65}
66
67/// Product-owned locale choices used by [`RequestLocale`].
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct RequestLocaleConfig {
70    supported: Vec<String>,
71    fallback_index: usize,
72    query_override: LocaleQueryOverride,
73}
74
75impl RequestLocaleConfig {
76    /// Validates supported locales, fallback, and query override behavior.
77    pub fn new<I, L>(
78        supported: I,
79        fallback: impl AsRef<str>,
80        query_override: LocaleQueryOverride,
81    ) -> Result<Self, RequestLocaleConfigError>
82    where
83        I: IntoIterator<Item = L>,
84        L: Into<String>,
85    {
86        let supported: Vec<String> = supported.into_iter().map(Into::into).collect();
87        if supported.is_empty() {
88            return Err(RequestLocaleConfigError::EmptySupportedLocales);
89        }
90        if supported.len() > MAX_SUPPORTED_LOCALES {
91            return Err(RequestLocaleConfigError::TooManySupportedLocales);
92        }
93
94        let mut normalized = HashSet::with_capacity(supported.len());
95        for locale in &supported {
96            let lower = locale.to_ascii_lowercase();
97            if locale.len() > MAX_LOCALE_TAG_BYTES
98                || !valid_language_tag(locale)
99                || !normalized.insert(lower)
100            {
101                return Err(RequestLocaleConfigError::InvalidSupportedLocale);
102            }
103        }
104
105        let fallback_index = supported
106            .iter()
107            .position(|locale| locale.eq_ignore_ascii_case(fallback.as_ref()))
108            .ok_or(RequestLocaleConfigError::UnsupportedFallback)?;
109
110        if let LocaleQueryOverride::Parameter(name) = &query_override {
111            LocaleQueryOverride::parameter(name.clone())?;
112        }
113
114        Ok(Self {
115            supported,
116            fallback_index,
117            query_override,
118        })
119    }
120
121    fn fallback(&self) -> &str {
122        &self.supported[self.fallback_index]
123    }
124}
125
126/// A locale selected from the configured supported set for one request.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct RequestLocale(String);
129
130impl RequestLocale {
131    /// Returns the configured spelling of the selected locale.
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135
136    /// Consumes the extractor and returns the selected locale.
137    pub fn into_string(self) -> String {
138        self.0
139    }
140}
141
142/// Predictable malformed-input failures from [`RequestLocale`].
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum RequestLocaleRejection {
145    /// The raw query exceeds [`MAX_LOCALE_QUERY_BYTES`].
146    QueryTooLong,
147    /// Percent decoding or UTF-8 decoding of the query failed.
148    MalformedQuery,
149    /// The configured locale query parameter appeared more than once.
150    DuplicateQueryOverride,
151    /// The explicit query locale is not in the supported set.
152    UnsupportedQueryLocale,
153    /// The combined `Accept-Language` values exceed [`MAX_ACCEPT_LANGUAGE_BYTES`].
154    HeaderTooLong,
155    /// `Accept-Language` contains invalid bytes, ranges, parameters, or quality values.
156    MalformedHeader,
157}
158
159impl IntoResponse for RequestLocaleRejection {
160    fn into_response(self) -> Response {
161        let (field, message) = match self {
162            Self::QueryTooLong => ("query", "is too long"),
163            Self::MalformedQuery => ("query", "must use valid percent-encoded UTF-8"),
164            Self::DuplicateQueryOverride => ("locale", "must be supplied at most once"),
165            Self::UnsupportedQueryLocale => ("locale", "must be a supported locale"),
166            Self::HeaderTooLong => ("acceptLanguage", "is too long"),
167            Self::MalformedHeader => ("acceptLanguage", "is malformed"),
168        };
169        ApiError::validation_field(field, message).into_response()
170    }
171}
172
173impl<S> FromRequestParts<S> for RequestLocale
174where
175    RequestLocaleConfig: FromRef<S>,
176    S: Send + Sync,
177{
178    type Rejection = RequestLocaleRejection;
179
180    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
181        let config = RequestLocaleConfig::from_ref(state);
182
183        if let LocaleQueryOverride::Parameter(parameter) = &config.query_override
184            && let Some(locale) = query_override(parts.uri.query(), parameter, &config)?
185        {
186            return Ok(Self(locale.to_owned()));
187        }
188
189        let locale = accept_language(parts, &config)?.unwrap_or_else(|| config.fallback());
190        Ok(Self(locale.to_owned()))
191    }
192}
193
194fn query_override<'a>(
195    query: Option<&str>,
196    parameter: &str,
197    config: &'a RequestLocaleConfig,
198) -> Result<Option<&'a str>, RequestLocaleRejection> {
199    let Some(query) = query else {
200        return Ok(None);
201    };
202    if query.len() > MAX_LOCALE_QUERY_BYTES {
203        return Err(RequestLocaleRejection::QueryTooLong);
204    }
205
206    let mut selected = None;
207    for pair in query.split('&') {
208        let (raw_key, raw_value) = pair.split_once('=').unwrap_or((pair, ""));
209        let key = decode_query_component(raw_key)?;
210        let value = decode_query_component(raw_value)?;
211        if key != parameter {
212            continue;
213        }
214        if selected.is_some() {
215            return Err(RequestLocaleRejection::DuplicateQueryOverride);
216        }
217        selected = Some(value);
218    }
219
220    let Some(selected) = selected else {
221        return Ok(None);
222    };
223    lookup_supported(&selected, &config.supported)
224        .map(Some)
225        .ok_or(RequestLocaleRejection::UnsupportedQueryLocale)
226}
227
228fn decode_query_component(value: &str) -> Result<String, RequestLocaleRejection> {
229    let bytes = value.as_bytes();
230    let mut decoded = Vec::with_capacity(bytes.len());
231    let mut index = 0;
232    while index < bytes.len() {
233        match bytes[index] {
234            b'+' => decoded.push(b' '),
235            b'%' => {
236                let high = bytes
237                    .get(index + 1)
238                    .and_then(|byte| hex_value(*byte))
239                    .ok_or(RequestLocaleRejection::MalformedQuery)?;
240                let low = bytes
241                    .get(index + 2)
242                    .and_then(|byte| hex_value(*byte))
243                    .ok_or(RequestLocaleRejection::MalformedQuery)?;
244                decoded.push((high << 4) | low);
245                index += 2;
246            }
247            byte => decoded.push(byte),
248        }
249        index += 1;
250    }
251    String::from_utf8(decoded).map_err(|_| RequestLocaleRejection::MalformedQuery)
252}
253
254fn hex_value(byte: u8) -> Option<u8> {
255    match byte {
256        b'0'..=b'9' => Some(byte - b'0'),
257        b'a'..=b'f' => Some(byte - b'a' + 10),
258        b'A'..=b'F' => Some(byte - b'A' + 10),
259        _ => None,
260    }
261}
262
263fn accept_language<'a>(
264    parts: &Parts,
265    config: &'a RequestLocaleConfig,
266) -> Result<Option<&'a str>, RequestLocaleRejection> {
267    let mut combined = String::new();
268    let mut saw_header = false;
269    for value in parts.headers.get_all(header::ACCEPT_LANGUAGE) {
270        saw_header = true;
271        let value = value
272            .to_str()
273            .map_err(|_| RequestLocaleRejection::MalformedHeader)?;
274        let separator_bytes = usize::from(!combined.is_empty());
275        if combined.len() + separator_bytes + value.len() > MAX_ACCEPT_LANGUAGE_BYTES {
276            return Err(RequestLocaleRejection::HeaderTooLong);
277        }
278        if !combined.is_empty() {
279            combined.push(',');
280        }
281        combined.push_str(value);
282    }
283    if !saw_header {
284        return Ok(None);
285    }
286
287    let mut preferences = Vec::new();
288    for (position, item) in combined.split(',').enumerate() {
289        let (range, quality) = parse_language_range(item)?;
290        preferences.push((range, quality, position));
291    }
292    preferences.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.2.cmp(&right.2)));
293
294    let excluded: Vec<&str> = preferences
295        .iter()
296        .filter_map(|(range, quality, _)| (*quality == Quality::ZERO).then_some(*range))
297        .collect();
298    for (range, quality, _) in preferences {
299        if quality == Quality::ZERO {
300            continue;
301        }
302        let matched = lookup_supported_excluding(range, &config.supported, &excluded);
303        if let Some(matched) = matched {
304            return Ok(Some(matched));
305        }
306    }
307    Ok(None)
308}
309
310#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
311struct Quality(u16);
312
313impl Quality {
314    const ZERO: Self = Self(0);
315}
316
317fn parse_language_range(item: &str) -> Result<(&str, Quality), RequestLocaleRejection> {
318    let mut parts = item.trim().split(';');
319    let range = parts.next().unwrap_or_default().trim();
320    if range != "*" && !valid_language_tag(range) {
321        return Err(RequestLocaleRejection::MalformedHeader);
322    }
323
324    let quality = match parts.next() {
325        None => Quality(1_000),
326        Some(parameter) => {
327            let (name, value) = parameter
328                .trim()
329                .split_once('=')
330                .ok_or(RequestLocaleRejection::MalformedHeader)?;
331            if !name.trim().eq_ignore_ascii_case("q") || parts.next().is_some() {
332                return Err(RequestLocaleRejection::MalformedHeader);
333            }
334            parse_quality(value.trim())?
335        }
336    };
337    Ok((range, quality))
338}
339
340fn parse_quality(value: &str) -> Result<Quality, RequestLocaleRejection> {
341    let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
342    if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
343        return Err(RequestLocaleRejection::MalformedHeader);
344    }
345    let padded = format!("{fraction:0<3}");
346    let fraction = padded
347        .parse::<u16>()
348        .map_err(|_| RequestLocaleRejection::MalformedHeader)?;
349    match whole {
350        "0" => Ok(Quality(fraction)),
351        "1" if fraction == 0 => Ok(Quality(1_000)),
352        _ => Err(RequestLocaleRejection::MalformedHeader),
353    }
354}
355
356fn valid_language_tag(value: &str) -> bool {
357    if value.is_empty() || value.len() > MAX_LOCALE_TAG_BYTES {
358        return false;
359    }
360    value.split('-').enumerate().all(|(index, subtag)| {
361        !subtag.is_empty()
362            && subtag.len() <= 8
363            && if index == 0 {
364                subtag.bytes().all(|byte| byte.is_ascii_alphabetic())
365            } else {
366                subtag.bytes().all(|byte| byte.is_ascii_alphanumeric())
367            }
368    })
369}
370
371fn lookup_supported<'a>(range: &str, supported: &'a [String]) -> Option<&'a str> {
372    let mut candidate = range;
373    loop {
374        if let Some(locale) = supported
375            .iter()
376            .find(|locale| locale.eq_ignore_ascii_case(candidate))
377        {
378            return Some(locale);
379        }
380        let prefix = format!("{candidate}-");
381        if let Some(locale) = supported.iter().find(|locale| {
382            locale.len() > prefix.len() && locale[..prefix.len()].eq_ignore_ascii_case(&prefix)
383        }) {
384            return Some(locale);
385        }
386        let separator = candidate.rfind('-')?;
387        candidate = &candidate[..separator];
388    }
389}
390
391fn lookup_supported_excluding<'a>(
392    range: &str,
393    supported: &'a [String],
394    excluded: &[&str],
395) -> Option<&'a str> {
396    if range == "*" {
397        return supported
398            .iter()
399            .find(|locale| !is_excluded(locale, excluded))
400            .map(String::as_str);
401    }
402    let mut candidate = range;
403    loop {
404        if let Some(locale) = supported
405            .iter()
406            .find(|locale| locale.eq_ignore_ascii_case(candidate) && !is_excluded(locale, excluded))
407        {
408            return Some(locale);
409        }
410        let prefix = format!("{candidate}-");
411        if let Some(locale) = supported.iter().find(|locale| {
412            locale.len() > prefix.len()
413                && locale[..prefix.len()].eq_ignore_ascii_case(&prefix)
414                && !is_excluded(locale, excluded)
415        }) {
416            return Some(locale);
417        }
418        let separator = candidate.rfind('-')?;
419        candidate = &candidate[..separator];
420    }
421}
422
423fn is_excluded(locale: &str, excluded: &[&str]) -> bool {
424    excluded.iter().any(|range| {
425        if *range == "*" || locale.eq_ignore_ascii_case(range) {
426            return true;
427        }
428        let prefix = format!("{range}-");
429        locale.len() > prefix.len() && locale[..prefix.len()].eq_ignore_ascii_case(&prefix)
430    })
431}
432
433#[cfg(test)]
434mod tests {
435    use axum::{
436        Router,
437        body::Body,
438        extract::FromRef,
439        http::{Request, StatusCode},
440        response::IntoResponse,
441        routing::get,
442    };
443    use serde_json::{Value, json};
444    use tower::ServiceExt as _;
445
446    use super::*;
447
448    fn config(query_override: LocaleQueryOverride) -> RequestLocaleConfig {
449        RequestLocaleConfig::new(["en", "de", "es-MX"], "en", query_override)
450            .expect("valid locale config")
451    }
452
453    async fn extract(uri: &str, accept_language: Option<&str>) -> Result<String, u16> {
454        async fn handler(locale: RequestLocale) -> String {
455            locale.into_string()
456        }
457
458        let app = Router::new()
459            .route("/locale", get(handler))
460            .with_state(config(
461                LocaleQueryOverride::parameter("locale").expect("valid parameter"),
462            ));
463        let mut request = Request::builder().uri(uri);
464        if let Some(value) = accept_language {
465            request = request.header(header::ACCEPT_LANGUAGE, value);
466        }
467        let response = app
468            .oneshot(request.body(Body::empty()).expect("request"))
469            .await
470            .expect("response");
471        let status = response.status();
472        if !status.is_success() {
473            return Err(status.as_u16());
474        }
475        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
476            .await
477            .expect("body");
478        Ok(String::from_utf8(body.to_vec()).expect("UTF-8 body"))
479    }
480
481    #[test]
482    fn validates_configuration() {
483        assert_eq!(
484            RequestLocaleConfig::new(Vec::<String>::new(), "en", LocaleQueryOverride::Disabled),
485            Err(RequestLocaleConfigError::EmptySupportedLocales)
486        );
487        assert_eq!(
488            RequestLocaleConfig::new(["en", "EN"], "en", LocaleQueryOverride::Disabled),
489            Err(RequestLocaleConfigError::InvalidSupportedLocale)
490        );
491        assert_eq!(
492            RequestLocaleConfig::new(["en"], "de", LocaleQueryOverride::Disabled),
493            Err(RequestLocaleConfigError::UnsupportedFallback)
494        );
495        assert_eq!(
496            LocaleQueryOverride::parameter("bad parameter"),
497            Err(RequestLocaleConfigError::InvalidQueryParameter)
498        );
499    }
500
501    #[tokio::test]
502    async fn percent_decoded_query_override_wins() {
503        assert_eq!(
504            extract("/locale?other=x&loc%61le=es%2DMX", Some("de;q=1")).await,
505            Ok("es-MX".to_owned())
506        );
507        assert_eq!(
508            extract("/locale?locale=de-DE", Some("es-MX")).await,
509            Ok("de".to_owned())
510        );
511    }
512
513    #[tokio::test]
514    async fn quality_wins_and_header_order_breaks_ties() {
515        assert_eq!(
516            extract("/locale", Some("de;q=0.4, es;q=0.9, en;q=0.8")).await,
517            Ok("es-MX".to_owned())
518        );
519        assert_eq!(
520            extract("/locale", Some("es;q=0.8, de;q=0.8")).await,
521            Ok("es-MX".to_owned())
522        );
523        assert_eq!(
524            extract("/locale", Some("de;q=0, fr;q=1")).await,
525            Ok("en".to_owned())
526        );
527        assert_eq!(
528            extract("/locale", Some("*;q=1, en;q=0, de;q=0")).await,
529            Ok("es-MX".to_owned())
530        );
531    }
532
533    #[tokio::test]
534    async fn rejects_malformed_and_oversized_input() {
535        for uri in [
536            "/locale?locale=%ZZ",
537            "/locale?locale=de&locale=en",
538            "/locale?locale=fr",
539        ] {
540            assert_eq!(extract(uri, None).await, Err(400));
541        }
542        for header in [
543            "",
544            "de;q=1.1",
545            "de;q=0.1234",
546            "de;level=1",
547            "de,,en",
548            "de_ DE",
549        ] {
550            assert_eq!(extract("/locale", Some(header)).await, Err(400));
551        }
552        let oversized_header = "a".repeat(MAX_ACCEPT_LANGUAGE_BYTES + 1);
553        assert_eq!(extract("/locale", Some(&oversized_header)).await, Err(400));
554        let oversized_query = format!("/locale?{}", "a".repeat(MAX_LOCALE_QUERY_BYTES + 1));
555        assert_eq!(extract(&oversized_query, None).await, Err(400));
556    }
557
558    #[tokio::test]
559    async fn query_override_can_be_disabled() {
560        async fn handler(locale: RequestLocale) -> String {
561            locale.into_string()
562        }
563        let app = Router::new()
564            .route("/locale", get(handler))
565            .with_state(config(LocaleQueryOverride::Disabled));
566        let response = app
567            .oneshot(
568                Request::builder()
569                    .uri("/locale?locale=de")
570                    .header(header::ACCEPT_LANGUAGE, "es-MX")
571                    .body(Body::empty())
572                    .expect("request"),
573            )
574            .await
575            .expect("response");
576        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
577            .await
578            .expect("body");
579        assert_eq!(&body[..], b"es-MX");
580    }
581
582    #[tokio::test]
583    async fn supports_locale_config_inside_application_state() {
584        #[derive(Clone)]
585        struct AppState {
586            locales: RequestLocaleConfig,
587        }
588
589        impl FromRef<AppState> for RequestLocaleConfig {
590            fn from_ref(state: &AppState) -> Self {
591                state.locales.clone()
592            }
593        }
594
595        async fn handler(locale: RequestLocale) -> String {
596            locale.into_string()
597        }
598
599        let app = Router::new()
600            .route("/locale", get(handler))
601            .with_state(AppState {
602                locales: config(LocaleQueryOverride::Disabled),
603            });
604        let response = app
605            .oneshot(
606                Request::builder()
607                    .uri("/locale")
608                    .header(header::ACCEPT_LANGUAGE, "de")
609                    .body(Body::empty())
610                    .expect("request"),
611            )
612            .await
613            .expect("response");
614        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
615            .await
616            .expect("body");
617        assert_eq!(&body[..], b"de");
618    }
619
620    #[tokio::test]
621    async fn rejection_uses_the_safe_validation_envelope() {
622        let response = RequestLocaleRejection::MalformedHeader.into_response();
623        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
624        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
625            .await
626            .expect("body");
627        let body: Value = serde_json::from_slice(&bytes).expect("JSON body");
628        assert_eq!(body["error"]["code"], "validation_failed");
629        assert_eq!(
630            body["error"]["details"],
631            json!({"acceptLanguage": "is malformed"})
632        );
633    }
634
635    #[test]
636    fn parses_quality_without_floating_point() {
637        assert_eq!(parse_quality("0"), Ok(Quality(0)));
638        assert_eq!(parse_quality("0.5"), Ok(Quality(500)));
639        assert_eq!(parse_quality("1.000"), Ok(Quality(1_000)));
640        assert_eq!(
641            parse_quality("1.001"),
642            Err(RequestLocaleRejection::MalformedHeader)
643        );
644    }
645
646    #[test]
647    fn exact_regional_match_wins_before_configured_sibling() {
648        let supported = vec!["en-GB".to_owned(), "en-US".to_owned()];
649        assert_eq!(lookup_supported("en-US", &supported), Some("en-US"));
650        assert_eq!(
651            lookup_supported_excluding("en", &supported, &["en-GB"]),
652            Some("en-US")
653        );
654    }
655}