use std::{
future::IntoFuture,
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use anyhow::{Context, Result, anyhow};
use axum::{
Json, Router,
body::Body,
extract::{OriginalUri, State},
http::{Response, StatusCode, header},
response::IntoResponse,
routing::{get, post},
};
use jsonschema::{Validator, validator_for};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tokio::{
fs,
net::TcpListener,
sync::{Mutex, oneshot},
};
use ts_rs::TS;
use crate::io::{DocumentFormat, input::schema_with_defaults};
use super::assets::{EmbeddedAssets, FilesystemAssets, WebAssetProvider};
use crate::ui_ast::{UiAst, build_ui_ast};
pub struct WebSessionBuilder {
schema: Value,
defaults: Option<Value>,
title: Option<String>,
asset_provider: Arc<dyn WebAssetProvider>,
}
impl WebSessionBuilder {
pub fn new(schema: Value) -> Self {
Self {
schema,
defaults: None,
title: None,
#[allow(clippy::default_constructed_unit_structs)]
asset_provider: Arc::new(EmbeddedAssets::default()),
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_initial_data(mut self, data: Value) -> Self {
self.defaults = Some(data);
self
}
pub fn with_asset_provider(mut self, provider: Arc<dyn WebAssetProvider>) -> Self {
self.asset_provider = provider;
self
}
pub fn with_filesystem_assets<P: Into<PathBuf>>(mut self, root: P) -> Self {
self.asset_provider = Arc::new(FilesystemAssets::new(root));
self
}
pub fn build(mut self) -> Result<WebSessionConfig> {
let data = self
.defaults
.take()
.unwrap_or_else(|| Value::Object(Map::new()));
let schema = schema_with_defaults(&self.schema, &data);
let ui_ast = build_ui_ast(&schema)?;
Ok(WebSessionConfig {
title: self.title,
ui_ast,
data,
schema,
asset_provider: self.asset_provider,
})
}
}
#[derive(Debug, Clone)]
pub struct WebSessionConfig {
pub title: Option<String>,
pub ui_ast: UiAst,
pub data: Value,
pub schema: Value,
pub asset_provider: Arc<dyn WebAssetProvider>,
}
#[derive(Debug, Clone, Copy)]
pub struct ServeOptions {
pub host: IpAddr,
pub port: u16,
}
impl Default for ServeOptions {
fn default() -> Self {
Self {
host: IpAddr::from([127, 0, 0, 1]),
port: 0,
}
}
}
pub struct BoundSession {
router: Router,
handles: SessionHandles,
listener: TcpListener,
addr: SocketAddr,
}
impl BoundSession {
pub fn local_addr(&self) -> SocketAddr {
self.addr
}
pub async fn run(self) -> Result<Value> {
let (result_rx, shutdown_rx) = self.handles.into_parts();
let server = axum::serve(self.listener, self.router.into_make_service())
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.into_future();
tokio::pin!(server);
tokio::pin!(result_rx);
let value = tokio::select! {
value = &mut result_rx => {
value.map_err(|_| anyhow!("web session closed before emitting a value"))?
}
outcome = &mut server => {
outcome.context("web server exited before the session finished")?;
return Err(anyhow!("web server terminated prematurely"));
}
};
let _ = server.await;
Ok(value)
}
}
pub async fn bind_session(config: WebSessionConfig, options: ServeOptions) -> Result<BoundSession> {
let (router, handles) = session_router(config)?;
let listener = TcpListener::bind(SocketAddr::new(options.host, options.port))
.await
.context("failed to bind web listener")?;
let addr = listener
.local_addr()
.context("failed to read bound address")?;
Ok(BoundSession {
router,
handles,
listener,
addr,
})
}
pub async fn serve_session(config: WebSessionConfig, options: ServeOptions) -> Result<Value> {
bind_session(config, options).await?.run().await
}
pub fn session_router(config: WebSessionConfig) -> Result<(Router, SessionHandles)> {
let WebSessionConfig {
title,
ui_ast,
data,
schema,
asset_provider,
} = config;
let validator = Arc::new(validator_for(&schema).context("failed to compile JSON schema")?);
let (result_tx, result_rx) = oneshot::channel();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let shared = SharedState {
title,
ui_ast: Arc::new(ui_ast),
data: Arc::new(Mutex::new(data)),
formats: DocumentFormat::available_formats(),
validator,
finish_line: Arc::new(FinishLine {
result: Mutex::new(Some(result_tx)),
shutdown: Mutex::new(Some(shutdown_tx)),
}),
finished: Arc::new(AtomicBool::new(false)),
asset_provider,
};
let router = Router::new()
.route("/api/session", get(get_session))
.route("/api/session/export", get(get_session_export))
.route("/api/save", post(post_save))
.route("/api/exit", post(post_exit))
.route("/api/validate", post(post_validate))
.route("/api/preview", post(post_preview))
.fallback(static_assets)
.with_state(shared);
Ok((
router,
SessionHandles {
result_rx: Some(result_rx),
shutdown_rx: Some(shutdown_rx),
},
))
}
pub struct SessionHandles {
result_rx: Option<oneshot::Receiver<Value>>,
shutdown_rx: Option<oneshot::Receiver<()>>,
}
impl SessionHandles {
pub fn into_parts(mut self) -> (oneshot::Receiver<Value>, oneshot::Receiver<()>) {
let result = self
.result_rx
.take()
.expect("result receiver already consumed");
let shutdown = self
.shutdown_rx
.take()
.expect("shutdown receiver already consumed");
(result, shutdown)
}
}
#[derive(Clone)]
struct SharedState {
title: Option<String>,
ui_ast: Arc<UiAst>,
data: Arc<Mutex<Value>>,
formats: Vec<DocumentFormat>,
validator: Arc<Validator>,
finish_line: Arc<FinishLine>,
finished: Arc<AtomicBool>,
asset_provider: Arc<dyn WebAssetProvider>,
}
struct FinishLine {
result: Mutex<Option<oneshot::Sender<Value>>>,
shutdown: Mutex<Option<oneshot::Sender<()>>>,
}
impl FinishLine {
async fn complete(&self, value: Value) {
if let Some(tx) = self.result.lock().await.take() {
let _ = tx.send(value);
}
if let Some(tx) = self.shutdown.lock().await.take() {
let _ = tx.send(());
}
}
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct SessionResponse {
pub title: Option<String>,
pub ui_ast: UiAst,
#[ts(type = "Record<string, unknown>")]
pub data: Value,
pub formats: Vec<String>,
}
async fn get_session(State(state): State<SharedState>) -> impl IntoResponse {
Json(build_and_maybe_dump_session(&state).await)
}
async fn get_session_export(State(state): State<SharedState>) -> impl IntoResponse {
let payload = build_and_maybe_dump_session(&state).await;
(
[(
header::CONTENT_DISPOSITION,
"attachment; filename=\"session.json\"",
)],
Json(payload),
)
}
async fn build_and_maybe_dump_session(state: &SharedState) -> SessionResponse {
const DEFAULT_PATH: &str = "/tmp/schemaui-session.json";
let payload = SessionResponse {
title: state.title.clone(),
ui_ast: (*state.ui_ast).clone(),
data: state.data.lock().await.clone(),
formats: state.formats.iter().map(|f| f.to_string()).collect(),
};
if let Ok(serialized) = serde_json::to_vec_pretty(&payload) {
let path = std::env::var("SCHEMAUI_SESSION_DUMP").unwrap_or_else(|_| DEFAULT_PATH.into());
let _ = fs::write(path, serialized).await;
}
payload
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct SaveRequest {
#[ts(type = "Record<string, unknown>")]
pub data: Value,
}
async fn post_save(
State(state): State<SharedState>,
Json(req): Json<SaveRequest>,
) -> (StatusCode, Json<Value>) {
if state.finished.load(Ordering::SeqCst) {
return (
StatusCode::GONE,
Json(json!({"error": "session already closed"})),
);
}
*state.data.lock().await = req.data;
(StatusCode::OK, Json(json!({"status": "saved"})))
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct ExitRequest {
#[ts(type = "Record<string, unknown>")]
pub data: Value,
#[serde(default = "crate::web::session::default_true")]
pub commit: bool,
}
fn default_true() -> bool {
true
}
async fn post_exit(
State(state): State<SharedState>,
Json(req): Json<ExitRequest>,
) -> (StatusCode, Json<Value>) {
if state.finished.swap(true, Ordering::SeqCst) {
return (
StatusCode::GONE,
Json(json!({"error": "session already closed"})),
);
}
let final_value = if req.commit {
req.data
} else {
state.data.lock().await.clone()
};
state.finish_line.complete(final_value).await;
(StatusCode::OK, Json(json!({"status": "closing"})))
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct ValidateRequest {
#[ts(type = "Record<string, unknown>")]
pub data: Value,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct ValidationResponse {
pub ok: bool,
pub errors: Vec<FieldError>,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct FieldError {
pub pointer: String,
pub message: String,
}
async fn post_validate(
State(state): State<SharedState>,
Json(req): Json<ValidateRequest>,
) -> impl IntoResponse {
let mut errors = Vec::new();
for error in state.validator.iter_errors(&req.data) {
errors.push(FieldError {
pointer: error.instance_path().to_string(),
message: error.to_string(),
});
}
Json(ValidationResponse {
ok: errors.is_empty(),
errors,
})
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct PreviewRequest {
#[ts(type = "Record<string, unknown>")]
pub data: Value,
pub format: String,
#[serde(default = "crate::web::session::default_true")]
pub pretty: bool,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "web/types/")]
pub(crate) struct PreviewResponse {
pub payload: String,
}
async fn post_preview(
State(state): State<SharedState>,
Json(req): Json<PreviewRequest>,
) -> Result<Json<PreviewResponse>, (StatusCode, Json<Value>)> {
render_payload(&req.data, &req.format, req.pretty, &state.formats)
.map(|payload| Json(PreviewResponse { payload }))
.map_err(|err| (StatusCode::BAD_REQUEST, Json(json!({"error": err}))))
}
fn render_payload(
data: &Value,
format_keyword: &str,
pretty: bool,
allowed: &[DocumentFormat],
) -> Result<String, String> {
let format = DocumentFormat::from_keyword(format_keyword)?;
if !allowed.contains(&format) {
return Err(format!("format '{format}' is disabled for this build"));
}
encode_value(data, format, pretty).map_err(|err| err.to_string())
}
fn encode_value(
value: &Value,
format: DocumentFormat,
pretty: bool,
) -> Result<String, anyhow::Error> {
match format {
DocumentFormat::Json => {
if pretty {
Ok(serde_json::to_string_pretty(value)?)
} else {
Ok(serde_json::to_string(value)?)
}
}
#[cfg(feature = "yaml")]
DocumentFormat::Yaml => Ok(serde_yaml::to_string(value)?),
#[cfg(feature = "toml")]
DocumentFormat::Toml => {
if pretty {
Ok(toml::to_string_pretty(value)?)
} else {
Ok(toml::to_string(value)?)
}
}
#[allow(unreachable_patterns)]
#[cfg(all(not(feature = "yaml"), not(feature = "toml")))]
_ => Err(anyhow!("unsupported format")),
}
}
async fn static_assets(State(state): State<SharedState>, uri: OriginalUri) -> impl IntoResponse {
if let Some(asset) = state.asset_provider.load(uri.path()) {
let body = Body::from(asset.contents.into_owned());
Response::builder()
.status(StatusCode::OK)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CONTENT_TYPE, asset.mime)
.body(body)
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap()
})
} else {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("not found"))
.unwrap()
}
}