diff --git a/crates/rustango/src/admin/errors.rs b/crates/rustango/src/admin/errors.rs index 5333771..089299b 100644 --- a/crates/rustango/src/admin/errors.rs +++ b/crates/rustango/src/admin/errors.rs @@ -48,6 +48,9 @@ pub enum AdminError { /// yet migrated" case and surface a friendly hint. const PG_UNDEFINED_TABLE: &str = "42P01"; +/// MySQL error code 1146 — `ER_NO_SUCH_TABLE`. +const MYSQL_NO_SUCH_TABLE: &str = "1146"; + fn pg_undefined_table_error(e: &sqlx::Error) -> Option { let db = e.as_database_error()?; if db.code().as_deref() != Some(PG_UNDEFINED_TABLE) { @@ -65,9 +68,56 @@ fn pg_undefined_table_error(e: &sqlx::Error) -> Option { Some(name) } +/// Sqlite reports missing tables via the driver message `no such +/// table: ` — no structured code. Match the message shape so +/// host apps on the `sqlite` feature get the same friendly +/// "TableMissing" page Postgres hosts get instead of a 500 with a +/// raw correlation id (rustango-cms #260). +fn sqlite_undefined_table_error(e: &sqlx::Error) -> Option { + let db = e.as_database_error()?; + let msg = db.message(); + let rest = msg.strip_prefix("no such table: ")?; + // The message may include trailing context after the table name; + // take the first whitespace/punctuation-bounded identifier. + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// MySQL reports missing tables with error code 1146 + a message like +/// `Table 'db.table' doesn't exist`. Match the code first (most +/// reliable) and pull the unqualified table name from the message. +fn mysql_undefined_table_error(e: &sqlx::Error) -> Option { + let db = e.as_database_error()?; + if db.code().as_deref() != Some(MYSQL_NO_SUCH_TABLE) { + return None; + } + // Message shape: `Table 'demo.rustango_admin_users' doesn't exist`. + let msg = db.message(); + let (_, rest) = msg.split_once('\'')?; + let (qualified, _) = rest.split_once('\'')?; + let name = qualified + .rsplit_once('.') + .map(|(_, t)| t) + .unwrap_or(qualified); + Some(name.to_owned()) +} + +fn undefined_table_error(e: &sqlx::Error) -> Option { + pg_undefined_table_error(e) + .or_else(|| sqlite_undefined_table_error(e)) + .or_else(|| mysql_undefined_table_error(e)) +} + impl From for AdminError { fn from(e: sqlx::Error) -> Self { - if let Some(table) = pg_undefined_table_error(&e) { + if let Some(table) = undefined_table_error(&e) { return Self::TableMissing { table }; } Self::Internal(e.to_string()) @@ -76,9 +126,11 @@ impl From for AdminError { impl From for AdminError { fn from(e: crate::sql::ExecError) -> Self { - // ExecError wraps sqlx errors; unwrap to detect PG_UNDEFINED_TABLE. + // ExecError wraps sqlx errors; unwrap to detect the + // dialect-specific undefined-table signature so sqlite + + // mysql hosts get the friendly TableMissing page, not a 500. if let crate::sql::ExecError::Driver(sqlx_err) = &e { - if let Some(table) = pg_undefined_table_error(sqlx_err) { + if let Some(table) = undefined_table_error(sqlx_err) { return Self::TableMissing { table }; } } @@ -242,6 +294,42 @@ mod tests { ); } + // rustango-cms #260 — sqlite + mysql parsers extract the table + // name from the dialect-specific error message shape so the + // friendly TableMissing page fires on every backend, not just + // postgres. + #[test] + fn sqlite_undefined_table_extracts_name_from_message() { + // The sqlite error string used by `libsqlite3-sys` / + // `sqlx::sqlite::SqliteError` is exactly this prefix. + let raw = "(code: 1) no such table: rustango_admin_users"; + // Strip the libsqlite3 prefix that sqlx may or may not + // include — what we care about is the `no such table: …` + // segment landing in `.message()`. + let msg = raw.strip_prefix("(code: 1) ").unwrap_or(raw); + assert_eq!( + msg.strip_prefix("no such table: ").map(|rest| { + rest.chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect::() + }), + Some("rustango_admin_users".to_owned()) + ); + } + + #[test] + fn mysql_undefined_table_extracts_name_from_message() { + // MySQL's `.message()` reads `Table 'db.table' doesn't exist`. + let msg = "Table 'demo.rustango_admin_users' doesn't exist"; + let (_, rest) = msg.split_once('\'').unwrap(); + let (qualified, _) = rest.split_once('\'').unwrap(); + let name = qualified + .rsplit_once('.') + .map(|(_, t)| t) + .unwrap_or(qualified); + assert_eq!(name, "rustango_admin_users"); + } + /// TableMissing path is unchanged — it's already a sanitized /// HTML page that mentions only the table name (which the /// user already typed in the URL, so it's not a leak). diff --git a/examples/showcase/e2e/tests/i18n_demo/locale.spec.ts b/examples/showcase/e2e/tests/i18n_demo/locale.spec.ts new file mode 100644 index 0000000..cdd92ec --- /dev/null +++ b/examples/showcase/e2e/tests/i18n_demo/locale.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; + +/** + * Phase 5 — `LocaleMiddleware` + URL-prefix locale composition. + * Exercises the framework's documented pick order: + * + * cookie > Accept-Language > configured default + * + * plus the documented `Router::nest("/", ...)` pattern for + * URL-prefix locales. + */ + +test.describe('locale resolution', () => { + test('default is en when no header / cookie set', async ({ request }) => { + const resp = await request.get('/i18n/greeting'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body.locale).toBe('en'); + expect(body.greeting).toBe('Hello, world!'); + }); + + test('Accept-Language picks among available locales', async ({ request }) => { + const resp = await request.get('/i18n/greeting', { + headers: { 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8' }, + }); + const body = await resp.json(); + expect(body.locale).toBe('fr'); + expect(body.greeting).toBe('Bonjour, monde !'); + }); + + test('Accept-Language with no match falls back to default', async ({ request }) => { + const resp = await request.get('/i18n/greeting', { + headers: { 'Accept-Language': 'ja,zh' }, + }); + const body = await resp.json(); + expect(body.locale).toBe('en'); + }); + + test('cookie overrides Accept-Language', async ({ request }) => { + const resp = await request.get('/i18n/greeting', { + headers: { + 'Accept-Language': 'fr', + Cookie: 'django_language=es', + }, + }); + const body = await resp.json(); + expect(body.locale).toBe('es'); + expect(body.greeting).toBe('¡Hola, mundo!'); + }); + + test('unknown cookie value falls through to Accept-Language', async ({ request }) => { + const resp = await request.get('/i18n/greeting', { + headers: { + 'Accept-Language': 'fr', + Cookie: 'django_language=de', + }, + }); + const body = await resp.json(); + expect(body.locale).toBe('fr'); + }); +}); + +test.describe('URL-prefix locale (Router::nest composition)', () => { + test('/fr/i18n/greeting renders French regardless of headers', async ({ request }) => { + const resp = await request.get('/fr/i18n/greeting', { + headers: { 'Accept-Language': 'en' }, + }); + const body = await resp.json(); + expect(body.locale).toBe('fr'); + expect(body.greeting).toBe('Bonjour, monde !'); + }); + + test('/es/i18n/greeting renders Spanish', async ({ request }) => { + const resp = await request.get('/es/i18n/greeting'); + const body = await resp.json(); + expect(body.locale).toBe('es'); + }); + + test('/en/i18n/greeting renders English even with cookie set', async ({ request }) => { + const resp = await request.get('/en/i18n/greeting', { + headers: { Cookie: 'django_language=fr' }, + }); + const body = await resp.json(); + expect(body.locale).toBe('en'); + }); + + test('unknown locale prefix is a 404 (not silently falling back)', async ({ request }) => { + const resp = await request.get('/de/i18n/greeting'); + expect(resp.status()).toBe(404); + }); +}); diff --git a/examples/showcase/src/apps/i18n_demo/mod.rs b/examples/showcase/src/apps/i18n_demo/mod.rs new file mode 100644 index 0000000..3096313 --- /dev/null +++ b/examples/showcase/src/apps/i18n_demo/mod.rs @@ -0,0 +1,5 @@ +//! `i18n_demo` sub-app — exercises `LocaleMiddleware` cookie / +//! Accept-Language resolution and the `Router::nest("/", …)` +//! URL-prefix locale pattern. Phase 5 of the E2E plan. + +pub mod urls; diff --git a/examples/showcase/src/apps/i18n_demo/urls.rs b/examples/showcase/src/apps/i18n_demo/urls.rs new file mode 100644 index 0000000..c3f94ef --- /dev/null +++ b/examples/showcase/src/apps/i18n_demo/urls.rs @@ -0,0 +1,59 @@ +//! HTTP routes for the i18n demo. +//! +//! * `/i18n/greeting` — single endpoint behind `LocaleMiddleware`, +//! exercises cookie + Accept-Language resolution. +//! * `/en/i18n/greeting` / `/fr/i18n/greeting` / `/es/i18n/greeting` +//! — three nested copies of the same handler, each pinned to one +//! locale via `LocaleMiddleware::new(&[lang]).default(lang)`. This +//! is the documented Router::nest pattern for URL-prefix locales +//! that the framework's middleware doc points at (see +//! `i18n/middleware.rs`). + +use axum::response::Json; +use axum::routing::get; +use axum::Router; +use rustango::i18n::middleware::{ActiveLocale, LocaleMiddleware}; + +#[must_use] +pub fn api() -> Router { + // Routes exposed at the top level (no URL prefix). LocaleMiddleware + // picks `ActiveLocale` from cookie / Accept-Language with `en` as + // the fallback. + let top = Router::new() + .route("/i18n/greeting", get(greeting)) + .layer(LocaleMiddleware::new(&["en", "fr", "es"]).default("en")); + + Router::new() + .merge(top) + .merge(locale_prefix_router("en")) + .merge(locale_prefix_router("fr")) + .merge(locale_prefix_router("es")) +} + +/// Build `//i18n/greeting` with the locale pinned to `lang` via +/// `LocaleMiddleware::new(&[lang]).default(lang)`. Mirrors Django's +/// `i18n_patterns` semantics: the URL determines the locale. +fn locale_prefix_router(lang: &'static str) -> Router { + let inner = Router::new() + .route("/i18n/greeting", get(greeting)) + .layer(LocaleMiddleware::new(&[lang]).default(lang)); + Router::new().nest(&format!("/{lang}"), inner) +} + +#[derive(serde::Serialize)] +struct GreetingOut { + locale: String, + greeting: &'static str, +} + +async fn greeting(active: ActiveLocale) -> Json { + let greeting = match active.0.as_str() { + "fr" => "Bonjour, monde !", + "es" => "¡Hola, mundo!", + _ => "Hello, world!", + }; + Json(GreetingOut { + locale: active.0, + greeting, + }) +} diff --git a/examples/showcase/src/apps/mod.rs b/examples/showcase/src/apps/mod.rs index 2817b91..74b1198 100644 --- a/examples/showcase/src/apps/mod.rs +++ b/examples/showcase/src/apps/mod.rs @@ -4,6 +4,7 @@ pub mod accounts; pub mod blog; +pub mod i18n_demo; pub mod shop; use axum::response::Json; @@ -20,6 +21,7 @@ pub fn api() -> Router { .merge(blog::urls::api()) .merge(shop::urls::api()) .merge(accounts::urls::api()) + .merge(i18n_demo::urls::api()) } /// Smoke endpoint — used by the E2E playwright suite's readiness @@ -38,6 +40,6 @@ async fn info() -> Json { "framework": "rustango", "version": env!("CARGO_PKG_VERSION"), "backend": backend, - "apps": ["accounts", "blog", "shop"], + "apps": ["accounts", "blog", "i18n_demo", "shop"], })) }