Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions crates/rustango/src/admin/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let db = e.as_database_error()?;
if db.code().as_deref() != Some(PG_UNDEFINED_TABLE) {
Expand All @@ -65,9 +68,56 @@ fn pg_undefined_table_error(e: &sqlx::Error) -> Option<String> {
Some(name)
}

/// Sqlite reports missing tables via the driver message `no such
/// table: <name>` — 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<String> {
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<String> {
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<String> {
pg_undefined_table_error(e)
.or_else(|| sqlite_undefined_table_error(e))
.or_else(|| mysql_undefined_table_error(e))
}

impl From<sqlx::Error> 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())
Expand All @@ -76,9 +126,11 @@ impl From<sqlx::Error> for AdminError {

impl From<crate::sql::ExecError> 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 };
}
}
Expand Down Expand Up @@ -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::<String>()
}),
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).
Expand Down
91 changes: 91 additions & 0 deletions examples/showcase/e2e/tests/i18n_demo/locale.spec.ts
Original file line number Diff line number Diff line change
@@ -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("/<lang>", ...)` 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);
});
});
5 changes: 5 additions & 0 deletions examples/showcase/src/apps/i18n_demo/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! `i18n_demo` sub-app — exercises `LocaleMiddleware` cookie /
//! Accept-Language resolution and the `Router::nest("/<lang>", …)`
//! URL-prefix locale pattern. Phase 5 of the E2E plan.

pub mod urls;
59 changes: 59 additions & 0 deletions examples/showcase/src/apps/i18n_demo/urls.rs
Original file line number Diff line number Diff line change
@@ -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 `/<lang>/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<GreetingOut> {
let greeting = match active.0.as_str() {
"fr" => "Bonjour, monde !",
"es" => "¡Hola, mundo!",
_ => "Hello, world!",
};
Json(GreetingOut {
locale: active.0,
greeting,
})
}
4 changes: 3 additions & 1 deletion examples/showcase/src/apps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

pub mod accounts;
pub mod blog;
pub mod i18n_demo;
pub mod shop;

use axum::response::Json;
Expand All @@ -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
Expand All @@ -38,6 +40,6 @@ async fn info() -> Json<serde_json::Value> {
"framework": "rustango",
"version": env!("CARGO_PKG_VERSION"),
"backend": backend,
"apps": ["accounts", "blog", "shop"],
"apps": ["accounts", "blog", "i18n_demo", "shop"],
}))
}
Loading