Skip to main content

reinhardt_middleware/
locale.rs

1//! Locale detection middleware
2//!
3//! Detects and sets the user's preferred language/locale based on multiple sources:
4//! - Accept-Language header
5//! - Cookie value
6//! - URL path prefix
7//!
8//! The detected locale is stored in a custom header for downstream handlers to use.
9
10use 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
16/// Header name for passing detected locale to handlers
17pub const LOCALE_HEADER: &str = "X-Locale";
18/// Cookie name for locale preference
19pub const LOCALE_COOKIE_NAME: &str = "django_language";
20
21/// Locale middleware configuration
22#[non_exhaustive]
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LocaleConfig {
25	/// Default locale to use when none is detected
26	pub default_locale: String,
27	/// List of supported locales (e.g., ["en", "ja", "fr"])
28	pub supported_locales: Vec<String>,
29	/// Check URL path for locale prefix (e.g., /ja/page)
30	pub check_url_path: bool,
31	/// Cookie name for storing locale preference
32	pub cookie_name: String,
33}
34
35impl LocaleConfig {
36	/// Create a new LocaleConfig with default settings
37	///
38	/// Default configuration:
39	/// - `default_locale`: "en"
40	/// - `supported_locales`: ["en"]
41	/// - `check_url_path`: false
42	/// - `cookie_name`: "django_language"
43	///
44	/// # Examples
45	///
46	/// ```
47	/// use reinhardt_middleware::locale::LocaleConfig;
48	///
49	/// let config = LocaleConfig::new();
50	/// assert_eq!(config.default_locale, "en");
51	/// ```
52	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	/// Create a new LocaleConfig with multiple supported locales
62	///
63	/// # Examples
64	///
65	/// ```
66	/// use reinhardt_middleware::locale::LocaleConfig;
67	///
68	/// let config = LocaleConfig::with_locales(
69	///     "en".to_string(),
70	///     vec!["en".to_string(), "ja".to_string(), "fr".to_string()]
71	/// );
72	/// assert_eq!(config.supported_locales.len(), 3);
73	/// ```
74	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
90/// Locale detection middleware
91///
92/// Detects the user's preferred locale from various sources and adds it
93/// to the request headers for use by downstream handlers.
94///
95/// Detection order:
96/// 1. URL path prefix (if enabled)
97/// 2. Cookie value
98/// 3. Accept-Language header
99/// 4. Default locale
100///
101/// # Examples
102///
103/// ```rust,no_run
104/// # #[tokio::main]
105/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
106/// use std::sync::Arc;
107/// use reinhardt_middleware::{LocaleMiddleware, locale::LocaleConfig};
108/// use reinhardt_http::{Handler, Middleware, Request, Response};
109/// use hyper::{StatusCode, Method, Version, HeaderMap};
110/// use bytes::Bytes;
111///
112/// struct TestHandler;
113///
114/// #[async_trait::async_trait]
115/// impl Handler for TestHandler {
116///     async fn handle(&self, request: Request) -> reinhardt_core::exception::Result<Response> {
117///         // Access detected locale from header
118///         let locale = request.headers.get("X-Locale")
119///             .and_then(|h| h.to_str().ok().map(String::from))
120///             .unwrap_or_else(|| "en".to_string());
121///         Ok(Response::new(StatusCode::OK).with_body(Bytes::from(locale)))
122///     }
123/// }
124///
125/// let config = LocaleConfig::with_locales(
126///     "en".to_string(),
127///     vec!["en".to_string(), "ja".to_string(), "fr".to_string()]
128/// );
129///
130/// let middleware = LocaleMiddleware::with_config(config);
131/// let handler = Arc::new(TestHandler);
132///
133/// let mut headers = HeaderMap::new();
134/// headers.insert(hyper::header::ACCEPT_LANGUAGE, "ja,en;q=0.9".parse().unwrap());
135///
136/// let request = Request::builder()
137///     .method(Method::GET)
138///     .uri("/page")
139///     .version(Version::HTTP_11)
140///     .headers(headers)
141///     .body(Bytes::new())
142///     .build()
143///     .unwrap();
144///
145/// let response = middleware.process(request, handler).await.unwrap();
146/// let body = String::from_utf8(response.body.to_vec()).unwrap();
147/// assert_eq!(body, "ja");
148/// # Ok(())
149/// # }
150/// ```
151pub struct LocaleMiddleware {
152	config: LocaleConfig,
153}
154
155impl LocaleMiddleware {
156	/// Create a new LocaleMiddleware with default configuration
157	///
158	/// # Examples
159	///
160	/// ```
161	/// use reinhardt_middleware::LocaleMiddleware;
162	///
163	/// let middleware = LocaleMiddleware::new();
164	/// ```
165	pub fn new() -> Self {
166		Self {
167			config: LocaleConfig::default(),
168		}
169	}
170
171	/// Create a new LocaleMiddleware with custom configuration
172	///
173	/// # Examples
174	///
175	/// ```
176	/// use reinhardt_middleware::{LocaleMiddleware, locale::LocaleConfig};
177	///
178	/// let config = LocaleConfig::with_locales(
179	///     "en".to_string(),
180	///     vec!["en".to_string(), "ja".to_string()]
181	/// );
182	///
183	/// let middleware = LocaleMiddleware::with_config(config);
184	/// ```
185	pub fn with_config(config: LocaleConfig) -> Self {
186		Self { config }
187	}
188
189	/// Extract locale from URL path (e.g., /ja/page -> "ja")
190	fn locale_from_path(&self, path: &str) -> Option<String> {
191		if !self.config.check_url_path {
192			return None;
193		}
194
195		// Path format: /locale/... (e.g., /ja/page)
196		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	/// Extract locale from cookie
214	fn locale_from_cookie(&self, request: &Request) -> Option<String> {
215		let cookie_header = request.headers.get(COOKIE)?.to_str().ok()?;
216
217		// Parse cookies: "name1=value1; name2=value2"
218		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	/// Extract locale from Accept-Language header
234	fn locale_from_accept_language(&self, request: &Request) -> Option<String> {
235		let accept_lang = request.headers.get(ACCEPT_LANGUAGE)?.to_str().ok()?;
236
237		// Parse Accept-Language: "ja,en-US;q=0.9,en;q=0.8"
238		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			// Extract base language code (ja-JP -> ja, en-US -> en)
249			let base_lang = lang.split('-').next().unwrap_or(lang).to_string();
250			languages.push((base_lang, quality));
251		}
252
253		// Sort by quality score (descending)
254		languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
255
256		// Find first supported language
257		languages
258			.into_iter()
259			.map(|(lang, _)| lang)
260			.find(|lang| self.config.supported_locales.contains(lang))
261	}
262
263	/// Detect locale from all available sources
264	fn detect_locale(&self, request: &Request) -> String {
265		// Priority: URL path > Cookie > Accept-Language > Default
266		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		// Detect locale
283		let locale = self.detect_locale(&request);
284
285		// Add locale to request headers for downstream handlers
286		request.headers.insert(
287			LOCALE_HEADER,
288			locale
289				.parse()
290				.unwrap_or_else(|_| hyper::header::HeaderValue::from_static("en")),
291		);
292
293		// Process request with handler
294		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			// Echo back the detected locale
310			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"); // Highest quality score
393	}
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"); // Cookie takes precedence
446	}
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"); // URL path takes highest precedence
500	}
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"); // Falls back to default
524	}
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"); // Extracts base language code
548	}
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"); // Falls back to Accept-Language
573	}
574}