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
12pub const MAX_LOCALE_QUERY_BYTES: usize = 2_048;
14pub const MAX_ACCEPT_LANGUAGE_BYTES: usize = 1_024;
16pub const MAX_SUPPORTED_LOCALES: usize = 64;
18
19const MAX_LOCALE_TAG_BYTES: usize = 64;
20const MAX_QUERY_PARAMETER_BYTES: usize = 32;
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub enum LocaleQueryOverride {
25 Disabled,
27 Parameter(String),
29}
30
31impl LocaleQueryOverride {
32 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#[derive(Clone, Debug, Eq, Error, PartialEq)]
49pub enum RequestLocaleConfigError {
50 #[error("at least one supported locale is required")]
52 EmptySupportedLocales,
53 #[error("at most {MAX_SUPPORTED_LOCALES} supported locales are allowed")]
55 TooManySupportedLocales,
56 #[error("supported locale is empty, oversized, duplicated, or malformed")]
58 InvalidSupportedLocale,
59 #[error("fallback locale must be supported")]
61 UnsupportedFallback,
62 #[error("locale query parameter name is empty, oversized, or malformed")]
64 InvalidQueryParameter,
65}
66
67#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct RequestLocale(String);
129
130impl RequestLocale {
131 pub fn as_str(&self) -> &str {
133 &self.0
134 }
135
136 pub fn into_string(self) -> String {
138 self.0
139 }
140}
141
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum RequestLocaleRejection {
145 QueryTooLong,
147 MalformedQuery,
149 DuplicateQueryOverride,
151 UnsupportedQueryLocale,
153 HeaderTooLong,
155 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}