Files
Genarrative/server-rs/crates/api-server/src/runtime_settings.rs

372 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use module_runtime::{
RuntimePlatformTheme, RuntimeSettingsFieldError, build_runtime_setting_upsert_input,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
PutRuntimeSettingsRequest, RUNTIME_PLATFORM_THEME_DARK, RUNTIME_PLATFORM_THEME_LIGHT,
RuntimeSettingsResponse,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn get_runtime_settings(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let settings = state
.spacetime_client()
.get_runtime_settings(user_id)
.await
.map_err(|error| {
runtime_settings_error_response(
&request_context,
map_runtime_settings_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
RuntimeSettingsResponse {
music_volume: settings.music_volume,
platform_theme: settings.platform_theme.as_str().to_string(),
},
))
}
pub async fn put_runtime_settings(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutRuntimeSettingsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
runtime_settings_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": error.body_text(),
})),
)
})?;
let user_id = authenticated.claims().user_id().to_string();
let theme = parse_platform_theme_strict(&payload.platform_theme).ok_or_else(|| {
runtime_settings_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": "platformTheme 仅支持 light 或 dark",
})),
)
})?;
if !(0.0..=1.0).contains(&payload.music_volume) {
return Err(runtime_settings_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": "musicVolume 必须在 0 到 1 之间",
})),
));
}
let now_micros = current_utc_micros();
let prepared =
build_runtime_setting_upsert_input(user_id, payload.music_volume, theme, now_micros)
.map_err(|error| {
runtime_settings_error_response(
&request_context,
map_runtime_settings_prepare_error(error),
)
})?;
let settings = state
.spacetime_client()
.put_runtime_settings(
prepared.user_id,
prepared.music_volume,
prepared.platform_theme,
prepared.updated_at_micros,
)
.await
.map_err(|error| {
runtime_settings_error_response(
&request_context,
map_runtime_settings_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
RuntimeSettingsResponse {
music_volume: settings.music_volume,
platform_theme: settings.platform_theme.as_str().to_string(),
},
))
}
fn map_runtime_settings_prepare_error(error: RuntimeSettingsFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-settings",
"message": error.to_string(),
}))
}
fn map_runtime_settings_client_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn runtime_settings_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
fn parse_platform_theme_strict(raw: &str) -> Option<RuntimePlatformTheme> {
match raw.trim() {
RUNTIME_PLATFORM_THEME_LIGHT => Some(RuntimePlatformTheme::Light),
RUNTIME_PLATFORM_THEME_DARK => Some(RuntimePlatformTheme::Dark),
_ => None,
}
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn runtime_settings_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/settings")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn runtime_settings_rejects_invalid_theme_with_envelope() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"musicVolume": 0.42,
"platformTheme": "mythic"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("runtime-settings".to_string())
);
}
#[tokio::test]
#[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module验证 PUT/GET settings 主链"]
async fn runtime_settings_round_trip_against_local_spacetimedb() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let put_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"musicVolume": 1.4,
"platformTheme": "dark"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(put_response.status(), StatusCode::OK);
let put_body = put_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let put_payload: Value =
serde_json::from_slice(&put_body).expect("response body should be valid json");
assert_eq!(
put_payload["data"]["platformTheme"],
Value::String("dark".to_string())
);
assert_eq!(put_payload["data"]["musicVolume"], json!(1.0));
let get_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/settings")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_body = get_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let get_payload: Value =
serde_json::from_slice(&get_body).expect("response body should be valid json");
assert_eq!(
get_payload["data"]["platformTheme"],
Value::String("dark".to_string())
);
assert_eq!(get_payload["data"]["musicVolume"], json!(1.0));
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138106", "secret123")
.await
.id;
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_settings",
),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("设置用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}