feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Vendored
+44
@@ -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?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user