From 2460a1368dd509f2fa1cdb0692207df005224f0b Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 14:52:09 +0200 Subject: [PATCH] feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config --- crates/api/src/config.rs | 29 ++++++++++++++++++++ crates/api/src/lib.rs | 6 +++++ crates/api/src/openapi.rs | 5 +++- crates/api/tests/admin.rs | 2 ++ crates/api/tests/admin_catalog.rs | 2 ++ crates/api/tests/admin_fields.rs | 2 ++ crates/api/tests/admin_objects.rs | 2 ++ crates/api/tests/admin_search.rs | 2 ++ crates/api/tests/config.rs | 41 ++++++++++++++++++++++++++++ crates/api/tests/health.rs | 2 ++ crates/api/tests/public.rs | 2 ++ crates/api/tests/reindex.rs | 2 ++ crates/server/src/config.rs | 17 ++++++++++++ crates/server/src/lib.rs | 2 ++ crates/server/tests/serve.rs | 2 ++ web/src/api/schema.d.ts | 44 +++++++++++++++++++++++++++++++ 16 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 crates/api/src/config.rs create mode 100644 crates/api/tests/config.rs diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs new file mode 100644 index 0000000..1765ff7 --- /dev/null +++ b/crates/api/src/config.rs @@ -0,0 +1,29 @@ +use axum::{Json, Router, extract::State, routing::get}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::AppState; + +/// Public, non-sensitive instance configuration the SPA needs before login. +#[derive(Serialize, ToSchema)] +pub(crate) struct ConfigView { + /// User-facing product name. + pub app_name: String, + /// Default UI/content language (i18n key, e.g. "sv"). + pub default_language: String, + /// Default display timezone (IANA name). Storage is UTC; this is a display hint. + pub default_timezone: String, +} + +#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))] +pub(crate) async fn get_config(State(state): State) -> Json { + Json(ConfigView { + app_name: state.app_name.clone(), + default_language: state.default_language.clone(), + default_timezone: state.default_timezone.clone(), + }) +} + +pub(crate) fn routes() -> Router { + Router::new().route("/api/config", get(get_config)) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index ebf0c9b..bb65a8c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -5,6 +5,7 @@ mod admin_authorities; mod admin_objects; mod admin_search; mod admin_vocab; +mod config; mod health; mod openapi; mod pagination; @@ -30,6 +31,10 @@ pub struct AppState { /// Search client for on-write index sync. `None` disables indexing (search is a /// best-effort feature; absent when Meilisearch is not configured). pub search: Option, + /// Instance default UI/content language (from config). + pub default_language: String, + /// Instance default display timezone, IANA name (from config). Storage stays UTC. + pub default_timezone: String, } /// Best-effort: keep the search index in step with a catalogue write that has already @@ -58,6 +63,7 @@ pub fn build_app(state: AppState) -> Router { .with_expiry(Expiry::OnInactivity(Duration::hours(8))); Router::new() + .merge(config::routes()) .merge(health::routes()) .merge(openapi::routes()) .merge(public::routes()) diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index ea75cbc..c176145 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; use crate::{ - AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public, + AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, config, health, + public, }; #[derive(OpenApi)] #[openapi( paths( + config::get_config, health::live, health::ready, public::list_objects, @@ -34,6 +36,7 @@ use crate::{ admin_authorities::create_authority ), components(schemas( + config::ConfigView, health::Live, health::Ready, public::PublicView, diff --git a/crates/api/tests/admin.rs b/crates/api/tests/admin.rs index 631a3d8..1aec9ed 100644 --- a/crates/api/tests/admin.rs +++ b/crates/api/tests/admin.rs @@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index 413f098..04aec01 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_fields.rs b/crates/api/tests/admin_fields.rs index f4408d3..8a79c2e 100644 --- a/crates/api/tests/admin_fields.rs +++ b/crates/api/tests/admin_fields.rs @@ -33,6 +33,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_objects.rs b/crates/api/tests/admin_objects.rs index 441ecb1..fe6a677 100644 --- a/crates/api/tests/admin_objects.rs +++ b/crates/api/tests/admin_objects.rs @@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_search.rs b/crates/api/tests/admin_search.rs index e552599..15236dd 100644 --- a/crates/api/tests/admin_search.rs +++ b/crates/api/tests/admin_search.rs @@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option) -> AppState { app_name: "Test".into(), cookie_secure: false, search, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/config.rs b/crates/api/tests/config.rs new file mode 100644 index 0000000..4d8b743 --- /dev/null +++ b/crates/api/tests/config.rs @@ -0,0 +1,41 @@ +use api::{AppState, build_app}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; + +fn state(pool: PgPool) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test Museum".into(), + cookie_secure: false, + search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), + } +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn config_is_public_and_reflects_state(pool: PgPool) { + let app = build_app(state(pool)); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = + serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(body["app_name"], "Test Museum"); + assert_eq!(body["default_language"], "sv"); + assert_eq!(body["default_timezone"], "Europe/Stockholm"); +} diff --git a/crates/api/tests/health.rs b/crates/api/tests/health.rs index 806ad66..53e9405 100644 --- a/crates/api/tests/health.rs +++ b/crates/api/tests/health.rs @@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState { app_name: app_name.to_string(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/public.rs b/crates/api/tests/public.rs index b09737c..1d1519d 100644 --- a/crates/api/tests/public.rs +++ b/crates/api/tests/public.rs @@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".to_string(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/reindex.rs b/crates/api/tests/reindex.rs index 78392ec..ad77105 100644 --- a/crates/api/tests/reindex.rs +++ b/crates/api/tests/reindex.rs @@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState { app_name: "Test".into(), cookie_secure: false, search: Some(search), + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 08e809e..869b7e5 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -50,4 +50,21 @@ pub struct Config { default_value_t = 5 )] pub db_max_connections: u32, + + /// Default UI + content-authoring language for this instance (i18n key, e.g. "sv"). + #[arg( + long = "default-language", + env = "DEFAULT_LANGUAGE", + default_value = "sv" + )] + pub default_language: String, + + /// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC; + /// this is a display hint surfaced to clients (and, later, server-side renderers). + #[arg( + long = "default-timezone", + env = "DEFAULT_TIMEZONE", + default_value = "Europe/Stockholm" + )] + pub default_timezone: String, } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 53951b2..760668e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -53,6 +53,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> { app_name: config.app_name, cookie_secure: config.cookie_secure, search, + default_language: config.default_language, + default_timezone: config.default_timezone, }; let listener = TcpListener::bind(&config.bind_addr) diff --git a/crates/server/tests/serve.rs b/crates/server/tests/serve.rs index edf402a..7ab329f 100644 --- a/crates/server/tests/serve.rs +++ b/crates/server/tests/serve.rs @@ -17,6 +17,8 @@ async fn serves_health_live_over_tcp() { app_name: "Test".to_string(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index e0095dc..3fc7b8e 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -238,6 +238,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_config"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/public/objects": { parameters: { query?: never; @@ -355,6 +371,15 @@ export interface components { kind: components["schemas"]["AuthorityKind"]; labels: components["schemas"]["LabelView"][]; }; + /** @description Public, non-sensitive instance configuration the SPA needs before login. */ + ConfigView: { + /** @description User-facing product name. */ + app_name: string; + /** @description Default UI/content language (i18n key, e.g. "sv"). */ + default_language: string; + /** @description Default display timezone (IANA name). Storage is UTC; this is a display hint. */ + default_timezone: string; + }; CreatedField: { key: string; }; @@ -1308,6 +1333,25 @@ export interface operations { }; }; }; + get_config: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigView"]; + }; + }; + }; + }; list_objects: { parameters: { query?: {