1use async_trait::async_trait;
11use hyper::header::{ACCEPT_LANGUAGE, COOKIE};
12use reinhardt_http::{Handler, Middleware, Request, Response, Result};
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16pub const LOCALE_HEADER: &str = "X-Locale";
18pub const LOCALE_COOKIE_NAME: &str = "django_language";
20
21#[non_exhaustive]
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LocaleConfig {
25 pub default_locale: String,
27 pub supported_locales: Vec<String>,
29 pub check_url_path: bool,
31 pub cookie_name: String,
33}
34
35impl LocaleConfig {
36 pub fn new() -> Self {
53 Self {
54 default_locale: "en".to_string(),
55 supported_locales: vec!["en".to_string()],
56 check_url_path: false,
57 cookie_name: LOCALE_COOKIE_NAME.to_string(),
58 }
59 }
60
61 pub fn with_locales(default: String, supported: Vec<String>) -> Self {
75 Self {
76 default_locale: default,
77 supported_locales: supported,
78 check_url_path: false,
79 cookie_name: LOCALE_COOKIE_NAME.to_string(),
80 }
81 }
82}
83
84impl Default for LocaleConfig {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90pub struct LocaleMiddleware {
152 config: LocaleConfig,
153}
154
155impl LocaleMiddleware {
156 pub fn new() -> Self {
166 Self {
167 config: LocaleConfig::default(),
168 }
169 }
170
171 pub fn with_config(config: LocaleConfig) -> Self {
186 Self { config }
187 }
188
189 fn locale_from_path(&self, path: &str) -> Option<String> {
191 if !self.config.check_url_path {
192 return None;
193 }
194
195 let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
197 if parts.is_empty() {
198 return None;
199 }
200
201 let potential_locale = parts[0];
202 if self
203 .config
204 .supported_locales
205 .contains(&potential_locale.to_string())
206 {
207 return Some(potential_locale.to_string());
208 }
209
210 None
211 }
212
213 fn locale_from_cookie(&self, request: &Request) -> Option<String> {
215 let cookie_header = request.headers.get(COOKIE)?.to_str().ok()?;
216
217 for cookie in cookie_header.split(';') {
219 let cookie = cookie.trim();
220 if let Some((name, value)) = cookie.split_once('=')
221 && name == self.config.cookie_name
222 {
223 let locale = value.to_string();
224 if self.config.supported_locales.contains(&locale) {
225 return Some(locale);
226 }
227 }
228 }
229
230 None
231 }
232
233 fn locale_from_accept_language(&self, request: &Request) -> Option<String> {
235 let accept_lang = request.headers.get(ACCEPT_LANGUAGE)?.to_str().ok()?;
236
237 let mut languages: Vec<(String, f32)> = Vec::new();
239
240 for lang_spec in accept_lang.split(',') {
241 let lang_spec = lang_spec.trim();
242 let (lang, quality) = if let Some((l, q)) = lang_spec.split_once(";q=") {
243 (l.trim(), q.parse::<f32>().unwrap_or(1.0))
244 } else {
245 (lang_spec, 1.0)
246 };
247
248 let base_lang = lang.split('-').next().unwrap_or(lang).to_string();
250 languages.push((base_lang, quality));
251 }
252
253 languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
255
256 languages
258 .into_iter()
259 .map(|(lang, _)| lang)
260 .find(|lang| self.config.supported_locales.contains(lang))
261 }
262
263 fn detect_locale(&self, request: &Request) -> String {
265 self.locale_from_path(request.uri.path())
267 .or_else(|| self.locale_from_cookie(request))
268 .or_else(|| self.locale_from_accept_language(request))
269 .unwrap_or_else(|| self.config.default_locale.clone())
270 }
271}
272
273impl Default for LocaleMiddleware {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279#[async_trait]
280impl Middleware for LocaleMiddleware {
281 async fn process(&self, mut request: Request, handler: Arc<dyn Handler>) -> Result<Response> {
282 let locale = self.detect_locale(&request);
284
285 request.headers.insert(
287 LOCALE_HEADER,
288 locale
289 .parse()
290 .unwrap_or_else(|_| hyper::header::HeaderValue::from_static("en")),
291 );
292
293 handler.handle(request).await
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use bytes::Bytes;
302 use hyper::{HeaderMap, Method, StatusCode, Version};
303
304 struct TestHandler;
305
306 #[async_trait]
307 impl Handler for TestHandler {
308 async fn handle(&self, request: Request) -> Result<Response> {
309 let locale = request
311 .headers
312 .get(LOCALE_HEADER)
313 .and_then(|h| h.to_str().ok())
314 .unwrap_or("unknown")
315 .to_string();
316 Ok(Response::new(StatusCode::OK).with_body(Bytes::from(locale)))
317 }
318 }
319
320 #[tokio::test]
321 async fn test_default_locale() {
322 let config = LocaleConfig::new();
323 let middleware = LocaleMiddleware::with_config(config);
324 let handler = Arc::new(TestHandler);
325
326 let request = Request::builder()
327 .method(Method::GET)
328 .uri("/page")
329 .version(Version::HTTP_11)
330 .headers(HeaderMap::new())
331 .body(Bytes::new())
332 .build()
333 .unwrap();
334
335 let response = middleware.process(request, handler).await.unwrap();
336 let body = String::from_utf8(response.body.to_vec()).unwrap();
337 assert_eq!(body, "en");
338 }
339
340 #[tokio::test]
341 async fn test_accept_language_detection() {
342 let config = LocaleConfig::with_locales(
343 "en".to_string(),
344 vec!["en".to_string(), "ja".to_string(), "fr".to_string()],
345 );
346 let middleware = LocaleMiddleware::with_config(config);
347 let handler = Arc::new(TestHandler);
348
349 let mut headers = HeaderMap::new();
350 headers.insert(ACCEPT_LANGUAGE, "ja,en;q=0.9".parse().unwrap());
351
352 let request = Request::builder()
353 .method(Method::GET)
354 .uri("/page")
355 .version(Version::HTTP_11)
356 .headers(headers)
357 .body(Bytes::new())
358 .build()
359 .unwrap();
360
361 let response = middleware.process(request, handler).await.unwrap();
362 let body = String::from_utf8(response.body.to_vec()).unwrap();
363 assert_eq!(body, "ja");
364 }
365
366 #[tokio::test]
367 async fn test_accept_language_with_quality() {
368 let config = LocaleConfig::with_locales(
369 "en".to_string(),
370 vec!["en".to_string(), "ja".to_string(), "fr".to_string()],
371 );
372 let middleware = LocaleMiddleware::with_config(config);
373 let handler = Arc::new(TestHandler);
374
375 let mut headers = HeaderMap::new();
376 headers.insert(
377 ACCEPT_LANGUAGE,
378 "fr;q=0.7,ja;q=0.9,en;q=0.8".parse().unwrap(),
379 );
380
381 let request = Request::builder()
382 .method(Method::GET)
383 .uri("/page")
384 .version(Version::HTTP_11)
385 .headers(headers)
386 .body(Bytes::new())
387 .build()
388 .unwrap();
389
390 let response = middleware.process(request, handler).await.unwrap();
391 let body = String::from_utf8(response.body.to_vec()).unwrap();
392 assert_eq!(body, "ja"); }
394
395 #[tokio::test]
396 async fn test_cookie_detection() {
397 let config = LocaleConfig::with_locales(
398 "en".to_string(),
399 vec!["en".to_string(), "ja".to_string(), "fr".to_string()],
400 );
401 let middleware = LocaleMiddleware::with_config(config);
402 let handler = Arc::new(TestHandler);
403
404 let mut headers = HeaderMap::new();
405 headers.insert(COOKIE, "django_language=fr; other=value".parse().unwrap());
406
407 let request = Request::builder()
408 .method(Method::GET)
409 .uri("/page")
410 .version(Version::HTTP_11)
411 .headers(headers)
412 .body(Bytes::new())
413 .build()
414 .unwrap();
415
416 let response = middleware.process(request, handler).await.unwrap();
417 let body = String::from_utf8(response.body.to_vec()).unwrap();
418 assert_eq!(body, "fr");
419 }
420
421 #[tokio::test]
422 async fn test_cookie_overrides_accept_language() {
423 let config = LocaleConfig::with_locales(
424 "en".to_string(),
425 vec!["en".to_string(), "ja".to_string(), "fr".to_string()],
426 );
427 let middleware = LocaleMiddleware::with_config(config);
428 let handler = Arc::new(TestHandler);
429
430 let mut headers = HeaderMap::new();
431 headers.insert(ACCEPT_LANGUAGE, "ja".parse().unwrap());
432 headers.insert(COOKIE, "django_language=fr".parse().unwrap());
433
434 let request = Request::builder()
435 .method(Method::GET)
436 .uri("/page")
437 .version(Version::HTTP_11)
438 .headers(headers)
439 .body(Bytes::new())
440 .build()
441 .unwrap();
442
443 let response = middleware.process(request, handler).await.unwrap();
444 let body = String::from_utf8(response.body.to_vec()).unwrap();
445 assert_eq!(body, "fr"); }
447
448 #[tokio::test]
449 async fn test_url_path_detection() {
450 let mut config = LocaleConfig::with_locales(
451 "en".to_string(),
452 vec!["en".to_string(), "ja".to_string(), "fr".to_string()],
453 );
454 config.check_url_path = true;
455
456 let middleware = LocaleMiddleware::with_config(config);
457 let handler = Arc::new(TestHandler);
458
459 let request = Request::builder()
460 .method(Method::GET)
461 .uri("/ja/page/subpage")
462 .version(Version::HTTP_11)
463 .headers(HeaderMap::new())
464 .body(Bytes::new())
465 .build()
466 .unwrap();
467
468 let response = middleware.process(request, handler).await.unwrap();
469 let body = String::from_utf8(response.body.to_vec()).unwrap();
470 assert_eq!(body, "ja");
471 }
472
473 #[tokio::test]
474 async fn test_url_path_overrides_all() {
475 let mut config = LocaleConfig::with_locales(
476 "en".to_string(),
477 vec!["en".to_string(), "ja".to_string(), "fr".to_string()],
478 );
479 config.check_url_path = true;
480
481 let middleware = LocaleMiddleware::with_config(config);
482 let handler = Arc::new(TestHandler);
483
484 let mut headers = HeaderMap::new();
485 headers.insert(ACCEPT_LANGUAGE, "ja".parse().unwrap());
486 headers.insert(COOKIE, "django_language=fr".parse().unwrap());
487
488 let request = Request::builder()
489 .method(Method::GET)
490 .uri("/en/page")
491 .version(Version::HTTP_11)
492 .headers(headers)
493 .body(Bytes::new())
494 .build()
495 .unwrap();
496
497 let response = middleware.process(request, handler).await.unwrap();
498 let body = String::from_utf8(response.body.to_vec()).unwrap();
499 assert_eq!(body, "en"); }
501
502 #[tokio::test]
503 async fn test_unsupported_locale_fallback() {
504 let config =
505 LocaleConfig::with_locales("en".to_string(), vec!["en".to_string(), "ja".to_string()]);
506 let middleware = LocaleMiddleware::with_config(config);
507 let handler = Arc::new(TestHandler);
508
509 let mut headers = HeaderMap::new();
510 headers.insert(ACCEPT_LANGUAGE, "de,fr;q=0.9".parse().unwrap());
511
512 let request = Request::builder()
513 .method(Method::GET)
514 .uri("/page")
515 .version(Version::HTTP_11)
516 .headers(headers)
517 .body(Bytes::new())
518 .build()
519 .unwrap();
520
521 let response = middleware.process(request, handler).await.unwrap();
522 let body = String::from_utf8(response.body.to_vec()).unwrap();
523 assert_eq!(body, "en"); }
525
526 #[tokio::test]
527 async fn test_accept_language_with_region() {
528 let config =
529 LocaleConfig::with_locales("en".to_string(), vec!["en".to_string(), "ja".to_string()]);
530 let middleware = LocaleMiddleware::with_config(config);
531 let handler = Arc::new(TestHandler);
532
533 let mut headers = HeaderMap::new();
534 headers.insert(ACCEPT_LANGUAGE, "ja-JP,en-US;q=0.9".parse().unwrap());
535
536 let request = Request::builder()
537 .method(Method::GET)
538 .uri("/page")
539 .version(Version::HTTP_11)
540 .headers(headers)
541 .body(Bytes::new())
542 .build()
543 .unwrap();
544
545 let response = middleware.process(request, handler).await.unwrap();
546 let body = String::from_utf8(response.body.to_vec()).unwrap();
547 assert_eq!(body, "ja"); }
549
550 #[tokio::test]
551 async fn test_invalid_cookie_value() {
552 let config =
553 LocaleConfig::with_locales("en".to_string(), vec!["en".to_string(), "ja".to_string()]);
554 let middleware = LocaleMiddleware::with_config(config);
555 let handler = Arc::new(TestHandler);
556
557 let mut headers = HeaderMap::new();
558 headers.insert(COOKIE, "django_language=invalid".parse().unwrap());
559 headers.insert(ACCEPT_LANGUAGE, "ja".parse().unwrap());
560
561 let request = Request::builder()
562 .method(Method::GET)
563 .uri("/page")
564 .version(Version::HTTP_11)
565 .headers(headers)
566 .body(Bytes::new())
567 .build()
568 .unwrap();
569
570 let response = middleware.process(request, handler).await.unwrap();
571 let body = String::from_utf8(response.body.to_vec()).unwrap();
572 assert_eq!(body, "ja"); }
574}