feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config

This commit is contained in:
2026-06-05 14:52:09 +02:00
parent 4a76d6043a
commit 2460a1368d
16 changed files with 161 additions and 1 deletions
+29
View File
@@ -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<AppState>) -> Json<ConfigView> {
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<AppState> {
Router::new().route("/api/config", get(get_config))
}
+6
View File
@@ -5,6 +5,7 @@ mod admin_authorities;
mod admin_objects; mod admin_objects;
mod admin_search; mod admin_search;
mod admin_vocab; mod admin_vocab;
mod config;
mod health; mod health;
mod openapi; mod openapi;
mod pagination; mod pagination;
@@ -30,6 +31,10 @@ pub struct AppState {
/// Search client for on-write index sync. `None` disables indexing (search is a /// Search client for on-write index sync. `None` disables indexing (search is a
/// best-effort feature; absent when Meilisearch is not configured). /// best-effort feature; absent when Meilisearch is not configured).
pub search: Option<search::SearchClient>, pub search: Option<search::SearchClient>,
/// 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 /// 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))); .with_expiry(Expiry::OnInactivity(Duration::hours(8)));
Router::new() Router::new()
.merge(config::routes())
.merge(health::routes()) .merge(health::routes())
.merge(openapi::routes()) .merge(openapi::routes())
.merge(public::routes()) .merge(public::routes())
+4 -1
View File
@@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get};
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{ 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)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
config::get_config,
health::live, health::live,
health::ready, health::ready,
public::list_objects, public::list_objects,
@@ -34,6 +36,7 @@ use crate::{
admin_authorities::create_authority admin_authorities::create_authority
), ),
components(schemas( components(schemas(
config::ConfigView,
health::Live, health::Live,
health::Ready, health::Ready,
public::PublicView, public::PublicView,
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -33,6 +33,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search, search,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+41
View File
@@ -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");
}
+2
View File
@@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
app_name: app_name.to_string(), app_name: app_name.to_string(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".to_string(), app_name: "Test".to_string(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+2
View File
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState {
app_name: "Test".into(), app_name: "Test".into(),
cookie_secure: false, cookie_secure: false,
search: Some(search), search: Some(search),
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
} }
} }
+17
View File
@@ -50,4 +50,21 @@ pub struct Config {
default_value_t = 5 default_value_t = 5
)] )]
pub db_max_connections: u32, 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,
} }
+2
View File
@@ -53,6 +53,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
app_name: config.app_name, app_name: config.app_name,
cookie_secure: config.cookie_secure, cookie_secure: config.cookie_secure,
search, search,
default_language: config.default_language,
default_timezone: config.default_timezone,
}; };
let listener = TcpListener::bind(&config.bind_addr) let listener = TcpListener::bind(&config.bind_addr)
+2
View File
@@ -17,6 +17,8 @@ async fn serves_health_live_over_tcp() {
app_name: "Test".to_string(), app_name: "Test".to_string(),
cookie_secure: false, cookie_secure: false,
search: None, search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}; };
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+44
View File
@@ -238,6 +238,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/public/objects": {
parameters: { parameters: {
query?: never; query?: never;
@@ -355,6 +371,15 @@ export interface components {
kind: components["schemas"]["AuthorityKind"]; kind: components["schemas"]["AuthorityKind"];
labels: components["schemas"]["LabelView"][]; 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: { CreatedField: {
key: string; 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: { list_objects: {
parameters: { parameters: {
query?: { query?: {