use crate::{ AppState, blocking, error::{ApiError, ApiResult, IssueDto, issues, source_issue}, store::{ MODEL_LIMITS, PreparedProject, SOURCE_LIMITS, StoredProject, check_model_size, check_source_size, validate_model, }, }; use axum::{ Json, Router, body::Bytes, extract::{ Path, Query, State, rejection::{BytesRejection, PathRejection, QueryRejection}, }, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, routing::{get, post}, }; use oa_core::source_project::{self, SourceBundle}; use oa_core::{Command, Id, Kind, Layer, Project, RelationKind}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; type ProjectPath = Result, PathRejection>; type Body = Result; pub(crate) fn router() -> Router { Router::new() .route("/health", get(health)) .route("/health/live", get(health)) .route("/health/ready", get(health)) .route("/openapi.json", get(openapi)) .route("/api/v1/openapi.json", get(openapi)) .route("/api/v1/projects", get(list_projects).post(create_project)) .route("/api/v1/projects/import", post(import_project)) .route( "/api/v1/projects/import-source-project", post(import_source_project), ) .route( "/api/v1/projects/{id}", get(get_project) .patch(rename_project) .delete(delete_project), ) .route("/api/v1/projects/{id}/model", get(get_model).put(put_model)) .route( "/api/v1/projects/{id}/source", get(get_source).put(put_source), ) .route( "/api/v1/projects/{id}/source-project", get(get_source_project).put(put_source_project), ) .route("/api/v1/projects/{id}/elements", get(list_elements)) .route("/api/v1/projects/{id}/relations", get(list_relations)) .route("/api/v1/projects/{id}/artifact-links", get(list_artifacts)) .route("/api/v1/projects/{id}/traces", get(list_traces)) .route("/api/v1/projects/{id}/query", post(neighborhood)) .route( "/api/v1/projects/{id}/validation", get(get_validation).post(validate_candidate), ) .route("/api/v1/projects/{id}/transactions", post(transactions)) .route( "/api/v1/projects/{id}/transitions/preview", post(preview_transition), ) .route( "/api/v1/projects/{id}/transitions/apply", post(commit_transition), ) .route( "/api/v1/projects/{id}/semantic-transitions/query", post(query_semantic_transitions), ) .route( "/api/v1/projects/{id}/semantic-transitions/preview", post(preview_semantic_transition), ) .route( "/api/v1/projects/{id}/semantic-transitions/apply", post(commit_semantic_transition), ) .fallback(|| async { ApiError::new( StatusCode::NOT_FOUND, "route_not_found", "API route does not exist.", ) }) .method_not_allowed_fallback(|| async { ApiError::new( StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed", "HTTP method is not supported by this route.", ) }) } async fn health() -> Json { Json(json!({"status":"ok"})) } async fn openapi() -> Response { ( [(header::CONTENT_TYPE, "application/json")], include_str!("../../../docs/openapi.json"), ) .into_response() } fn path(value: ProjectPath) -> ApiResult { value .map(|Path(id)| id) .map_err(|error| ApiError::bad_request(error.body_text())) } fn query(value: Result, QueryRejection>) -> ApiResult { value .map(|Query(value)| value) .map_err(|error| ApiError::bad_request(error.body_text())) } fn bytes(value: Body) -> ApiResult { value.map_err(|error| ApiError::new(error.status(), "invalid_body", error.body_text())) } fn json_body(body: &[u8]) -> ApiResult { serde_json::from_slice(body) .map_err(|error| ApiError::bad_request(format!("Invalid JSON: {error}"))) } fn media(headers: &HeaderMap, expected: &str) -> ApiResult<()> { let actual = headers .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(';').next()) .map(str::trim); if actual != Some(expected) { return Err(ApiError::new( StatusCode::UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type", format!("Content-Type must be {expected}."), )); } Ok(()) } fn expected(headers: &HeaderMap) -> ApiResult { let mut values = headers.get_all(header::IF_MATCH).iter(); let value = values .next() .ok_or_else(|| { ApiError::new( StatusCode::PRECONDITION_REQUIRED, "precondition_required", "Supply the project's ETag in If-Match.", ) })? .to_str() .map_err(|_| ApiError::bad_request("Invalid If-Match header."))?; if values.next().is_some() || value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') || value[1..value.len() - 1].contains(['"', ',']) { return Err(ApiError::bad_request( "If-Match must contain exactly one strong, quoted ETag; wildcard/list/weak tags are unsupported.", )); } Ok(value.to_owned()) } fn response(stored: &StoredProject, status: StatusCode, value: impl Serialize) -> Response { let mut response = (status, Json(value)).into_response(); response .headers_mut() .insert(header::ETAG, stored.etag().parse().expect("ETag is ASCII")); response } fn not_modified(stored: &StoredProject, headers: &HeaderMap) -> Option { let etag = stored.etag(); let matched = headers .get_all(header::IF_NONE_MATCH) .iter() .filter_map(|value| value.to_str().ok()) .flat_map(|value| value.split(',')) .map(str::trim) .any(|value| { value == "*" || value == etag || value.strip_prefix("W/") == Some(etag.as_str()) }); matched.then(|| { let mut response = StatusCode::NOT_MODIFIED.into_response(); response .headers_mut() .insert(header::ETAG, etag.parse().expect("ETag is ASCII")); response }) } fn summary(stored: &StoredProject) -> Value { json!({"resource_id":stored.resource_id,"revision":stored.revision,"model_id":stored.model.id, "schema_version":stored.model.schema_version,"name":stored.model.name, "elements":stored.model.elements.len(),"relations":stored.model.relations.len(),"migration":stored.migration}) } fn parse_source(body: &[u8]) -> ApiResult { let source = std::str::from_utf8(body).map_err(|_| ApiError::bad_request("Source must be UTF-8."))?; oa_core::language::parse_with_report_limited(source, MODEL_LIMITS).map_err(|diagnostics| { let code = if diagnostics.iter().any(|d| d.code == "model_limit") { "model_limit" } else { "invalid_source" }; let mut error = ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, code, "Arcadia source could not be applied.", ); error.issues = diagnostics .into_iter() .map(|issue| IssueDto { severity: "error", code: issue.code, message: issue.message, field: issue.field, object_id: issue.object_id, line: Some(issue.line), column: Some(issue.column), path: None, span: Some(issue.span.into()), related: vec![], }) .collect(); error }) } fn import_json(body: &[u8]) -> ApiResult { // Format is chosen by HTTP. The shared decoder owns supported older-schema migration. let _: Value = json_body(body)?; oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err( |error| match error { oa_core::persistence::FileError::Limit(detail) => { ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail) } oa_core::persistence::FileError::Model { message, issues } => { let mut error = ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "invalid_model", message); error.issues = issues .into_iter() .map(|issue| IssueDto { severity: "error", code: issue.code, message: issue.message, field: issue.field, object_id: issue.element, line: None, column: None, path: None, span: None, related: vec![], }) .collect(); error } oa_core::persistence::FileError::Json(error) => { ApiError::bad_request(format!("Invalid model JSON: {error}")) } error => ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, "invalid_model", error.to_string(), ), }, ) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct NameRequest { name: String, } async fn create_project( State(state): State, headers: HeaderMap, body: Body, ) -> ApiResult { media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: NameRequest = json_body(&body)?; let mut model = Project::empty(); model.name = request.name; created(store.create(model)?) }) .await } fn created(stored: StoredProject) -> ApiResult { let mut result = response(&stored, StatusCode::CREATED, summary(&stored)); result.headers_mut().insert( header::LOCATION, format!("/api/v1/projects/{}", stored.resource_id) .parse() .expect("UUID URL is ASCII"), ); Ok(result) } async fn import_project( State(state): State, headers: HeaderMap, body: Body, ) -> ApiResult { let is_json = media(&headers, "application/json").is_ok(); if !is_json { media(&headers, "text/plain")?; } let body = bytes(body)?; blocking(state, move |store| { let model = if is_json { import_json(&body)? } else { parse_source(&body)? }; created(store.create_decoded(model)?) }) .await } async fn import_source_project( State(state): State, headers: HeaderMap, body: Body, ) -> ApiResult { media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let bundle: SourceBundle = json_body(&body)?; created(store.create_source_project(bundle)?) }) .await } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct Page { limit: Option, after: Option, q: Option, } impl Page { fn limit(&self) -> ApiResult { let limit = self.limit.unwrap_or(100); if !(1..=1000).contains(&limit) { return Err(ApiError::bad_request("limit must be between 1 and 1000.")); } Ok(limit) } } async fn list_projects( State(state): State, pagination: Result, QueryRejection>, ) -> ApiResult { let page = query(pagination)?; let limit = page.limit()?; blocking(state, move |store| { let needle = page.q.as_deref().unwrap_or("").to_lowercase(); let catalog = store.catalog()?; let mut matches = catalog.iter().filter(|stored| { page.after.is_none_or(|id| stored.resource_id > id) && stored.name.to_lowercase().contains(&needle) }); let items: Vec<_> = matches.by_ref().take(limit).collect(); let next = if matches.next().is_some() { items.last().map(|stored| stored.resource_id) } else { None }; Ok(Json(json!({"items":items,"next_after":next})).into_response()) }) .await } async fn get_project( State(state): State, id: ProjectPath, headers: HeaderMap, ) -> ApiResult { let id = path(id)?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; Ok(not_modified(stored, &headers) .unwrap_or_else(|| response(stored, StatusCode::OK, stored))) }) .await } async fn rename_project( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: NameRequest = json_body(&body)?; let snapshot = store.checked(id, &expected)?; let current = &snapshot; let prepared = oa_core::prepare_command( ¤t.model, &Command::EditProject { id: current.model.id, alias: current.model.alias.clone(), name: request.name, description: current.model.description.clone(), }, ) .map_err(|error| { ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, "command_rejected", error.to_string(), ) })?; let prepared = PreparedProject::from_command(current, prepared)?; let (stored, changed) = store.replace(id, &expected, prepared)?; Ok(response( &stored, StatusCode::OK, json!({"revision":stored.revision,"changed":changed}), )) }) .await } async fn delete_project( State(state): State, id: ProjectPath, headers: HeaderMap, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; blocking(state, move |store| { store.delete(id, &expected)?; Ok(StatusCode::NO_CONTENT.into_response()) }) .await } async fn get_model( State(state): State, id: ProjectPath, headers: HeaderMap, ) -> ApiResult { let id = path(id)?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; Ok(not_modified(stored, &headers) .unwrap_or_else(|| response(stored, StatusCode::OK, &stored.model))) }) .await } async fn get_source( State(state): State, id: ProjectPath, headers: HeaderMap, ) -> ApiResult { let id = path(id)?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; if let Some(result) = not_modified(stored, &headers) { return Ok(result); } let mut result = ( [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], oa_core::language::format(&stored.model), ) .into_response(); result .headers_mut() .insert(header::ETAG, stored.etag().parse().expect("ETag is ASCII")); Ok(result) }) .await } async fn get_source_project( State(state): State, id: ProjectPath, headers: HeaderMap, ) -> ApiResult { let id = path(id)?; blocking(state, move |store| { let stored = store.get(id)?; Ok(not_modified(&stored, &headers) .unwrap_or_else(|| response(&stored, StatusCode::OK, &stored.source_project))) }) .await } async fn put_source_project( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { store.checked(id, &expected)?; let bundle: SourceBundle = json_body(&body)?; let prepared = PreparedProject::from_bundle(bundle)?; let diagnostics = prepared.source_issues(); let (stored, changed) = store.replace(id, &expected, prepared)?; Ok(response( &stored, StatusCode::OK, json!({ "revision": stored.revision, "changed": changed, "issues": diagnostics, "source_project": stored.source_project, "migration": stored.migration }), )) }) .await } async fn put_model( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { replace_model(state, id, headers, body, true).await } async fn put_source( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { replace_model(state, id, headers, body, false).await } async fn replace_model( state: AppState, id: ProjectPath, headers: HeaderMap, body: Body, is_json: bool, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media( &headers, if is_json { "application/json" } else { "text/plain" }, )?; let body = bytes(body)?; blocking(state, move |store| { store.checked(id, &expected)?; let model = if is_json { import_json(&body)? } else { parse_source(&body)? }; let (stored, changed) = store.replace_decoded(id, &expected, model)?; Ok(response( &stored, StatusCode::OK, json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration}), )) }) .await } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct ElementQuery { limit: Option, after: Option, revision: Option, kind: Option, layer: Option, alias: Option, q: Option, } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct RelationQuery { limit: Option, after: Option, revision: Option, kind: Option, source: Option, target: Option, incident_to: Option, } fn check_read_revision(stored: &StoredProject, revision: Option) -> ApiResult<()> { if revision.is_some_and(|revision| revision != stored.revision) { let mut error = ApiError::new( StatusCode::CONFLICT, "snapshot_changed", "Only the current revision is retained. Restart pagination from the current snapshot.", ); error.current_revision = Some(stored.revision); return Err(error); } Ok(()) } async fn list_elements( State(state): State, id: ProjectPath, parameters: Result, QueryRejection>, ) -> ApiResult { let id = path(id)?; let query = query(parameters)?; let limit = Page { limit: query.limit, ..Page::default() } .limit()?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; check_read_revision(stored, query.revision)?; let needle = query.q.as_deref().unwrap_or("").to_lowercase(); let mut items = Vec::new(); let mut next = None; for element in stored.model.elements.values() { if query.after.is_some_and(|id| element.id <= id) || query.kind.is_some_and(|kind| element.kind() != kind) || !element.name.to_lowercase().contains(&needle) || query .alias .as_ref() .is_some_and(|alias| element.alias != *alias) || query.layer.is_some_and(|layer| element.layer() != layer) { continue; } let value = serde_json::to_value(element) .map_err(|_| ApiError::internal("Element serialization failed."))?; if items.len() == limit { next = items .last() .and_then(|item: &Value| item.get("id")) .cloned(); break; } items.push(value); } Ok(response( stored, StatusCode::OK, json!({"revision":stored.revision,"items":items,"next_after":next}), )) }) .await } async fn list_relations( State(state): State, id: ProjectPath, parameters: Result, QueryRejection>, ) -> ApiResult { let id = path(id)?; let query = query(parameters)?; let limit = Page { limit: query.limit, ..Page::default() } .limit()?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; check_read_revision(stored, query.revision)?; let mut matches = stored.model.relations.values().filter(|relation| { query.after.is_none_or(|id| relation.id > id) && query.kind.is_none_or(|kind| relation.kind() == kind) && query.source.is_none_or(|id| relation.source() == id) && query.target.is_none_or(|id| relation.target() == id) && query .incident_to .is_none_or(|id| relation.source() == id || relation.target() == id) }); let items: Vec<_> = matches.by_ref().take(limit).collect(); let next = if matches.next().is_some() { items.last().map(|relation| relation.id) } else { None }; Ok(response( stored, StatusCode::OK, json!({"revision":stored.revision,"items":items,"next_after":next}), )) }) .await } #[derive(Default, Deserialize)] #[serde(rename_all = "snake_case")] enum Direction { Incoming, Outgoing, #[default] Both, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Neighborhood { roots: Vec, #[serde(default)] direction: Direction, #[serde(default)] relation_kinds: Vec, #[serde(default = "default_depth")] max_depth: usize, #[serde(default = "default_objects")] max_objects: usize, revision: Option, } fn default_depth() -> usize { 1 } fn default_objects() -> usize { 200 } async fn neighborhood( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let query: Neighborhood = json_body(&body)?; if query.roots.is_empty() || query.roots.len() > 100 || query.max_depth > 4 || !(1..=1000).contains(&query.max_objects) || query.roots.len() > query.max_objects { return Err(ApiError::bad_request("Use 1..100 roots, max_depth 0..4, and max_objects 1..1000 (at least the root count).")); } let snapshot = store.get(id)?; let stored = &snapshot; check_read_revision(stored, query.revision)?; if query.roots.iter().any(|id| !stored.model.elements.contains_key(id) && !stored.model.relations.contains_key(id)) { return Err(ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "missing_root", "Every root must name an existing element or relation.")); } let mut objects = BTreeSet::new(); let mut traversed: BTreeSet<_> = query.roots.into_iter().collect(); let mut queue: VecDeque<_> = traversed.iter().map(|id| (*id, 0)).collect(); for root in &traversed { endpoint_closure(&stored.model, *root, &mut objects); } if objects.len() > query.max_objects { return Err(ApiError::bad_request("Root relation endpoint closure exceeds max_objects.")); } let mut adjacency: BTreeMap> = BTreeMap::new(); for relation in stored.model.relations.values() { if !query.relation_kinds.is_empty() && !query.relation_kinds.contains(&relation.kind()) { continue; } if matches!(query.direction, Direction::Outgoing | Direction::Both) { adjacency.entry(relation.source()).or_default().push((relation.id, relation.target())); } if matches!(query.direction, Direction::Incoming | Direction::Both) { adjacency.entry(relation.target()).or_default().push((relation.id, relation.source())); } } let mut truncated = false; while let Some((current, depth)) = queue.pop_front() { if depth == query.max_depth { continue; } for &(relation, neighbor) in adjacency.get(¤t).into_iter().flatten() { let mut additions = BTreeSet::new(); endpoint_closure(&stored.model, relation, &mut additions); let cost = additions.difference(&objects).count(); if objects.len() + cost > query.max_objects { truncated = true; continue; } objects.extend(additions); if traversed.insert(neighbor) { queue.push_back((neighbor, depth + 1)); } } } Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision, "elements":objects.iter().filter_map(|id| stored.model.elements.get(id)).collect::>(), "relations":objects.iter().filter_map(|id| stored.model.relations.get(id)).collect::>(),"truncated":truncated}))) }).await } fn endpoint_closure(model: &Project, root: Id, objects: &mut BTreeSet) { let mut pending = vec![root]; while let Some(id) = pending.pop() { if !objects.insert(id) { continue; } if let Some(relation) = model.relations.get(&id) { pending.extend([relation.source(), relation.target()]); } } } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] struct LinkQuery { limit: Option, after: Option, revision: Option, object: Option, } async fn list_artifacts( State(state): State, id: ProjectPath, parameters: Result, QueryRejection>, ) -> ApiResult { list_links(state, id, parameters, true).await } async fn list_traces( State(state): State, id: ProjectPath, parameters: Result, QueryRejection>, ) -> ApiResult { list_links(state, id, parameters, false).await } async fn list_links( state: AppState, id: ProjectPath, parameters: Result, QueryRejection>, artifacts: bool, ) -> ApiResult { let id = path(id)?; let query = query(parameters)?; let limit = Page { limit: query.limit, ..Page::default() } .limit()?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; check_read_revision(stored, query.revision)?; let mut records = Vec::new(); if artifacts { for link in stored .model .artifact_links .values() .filter(|link| { query.after.is_none_or(|id| link.id > id) && query.object.is_none_or(|id| link.owner == id) }) .take(limit + 1) { records.push( serde_json::to_value(link) .map_err(|_| ApiError::internal("Artifact serialization failed."))?, ); } } else { for link in stored .model .traces .values() .filter(|link| { query.after.is_none_or(|id| link.id > id) && query.object.is_none_or(|id| link.oa == id || link.sa == id) }) .take(limit + 1) { records.push( serde_json::to_value(link) .map_err(|_| ApiError::internal("Trace serialization failed."))?, ); } } let next = if records.len() > limit { records.truncate(limit); records.last().and_then(|record| record.get("id")).cloned() } else { None }; Ok(response( stored, StatusCode::OK, json!({"revision":stored.revision,"items":records,"next_after":next}), )) }) .await } async fn get_validation(State(state): State, id: ProjectPath) -> ApiResult { let id = path(id)?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; Ok(response( stored, StatusCode::OK, json!({"revision":stored.revision,"valid":true,"issues":issues(&stored.model)}), )) }) .await } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ValidationRequest { format: String, // Keep RawValue directly in the struct: internally tagged enums buffer // their content and cannot preserve raw model JSON for duplicate-key checks. model: Option>, source: Option, source_project: Option, } async fn validate_candidate( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let snapshot = store.get(id)?; let stored = &snapshot; let request: ValidationRequest = json_body(&body)?; let parsed = match (request.format.as_str(), request.model, request.source, request.source_project) { ("json", Some(model), None, None) => import_json(model.get().as_bytes()), ("arcadia", None, Some(source), None) => parse_source(source.as_bytes()), ("source-project", None, None, Some(source_project)) => { let result = check_source_size(&source_project).and_then(|()| { // Advisory locations refer to submitted bytes, before Apply // would insert missing persistent identities. let diagnostics: Vec<_> = source_project::lint_bundle(&source_project, SOURCE_LIMITS).into_iter().map(source_issue).collect(); let migration = if !diagnostics.iter().any(|issue| issue.severity == "error") { PreparedProject::from_bundle(source_project)?.migration().cloned() } else { None }; Ok((diagnostics, migration)) }); let (diagnostics, migration) = result.unwrap_or_else(|error| (candidate_issues(error), None)); let valid = !diagnostics.iter().any(|issue| issue.severity == "error"); return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":migration}))); } _ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")), }; let (valid, issues, migration) = match parsed.and_then(|decoded| { validate_model(&decoded.project)?; Ok(decoded) }) { Ok(decoded) => (true, issues(&decoded.project), decoded.migration), Err(error) => (false, candidate_issues(error), None), }; Ok(response( stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":issues,"migration":migration}), )) }) .await } fn candidate_issues(error: ApiError) -> Vec { if !error.issues.is_empty() { return error.issues; } vec![IssueDto { severity: "error", code: error.code.into(), message: error.detail, field: None, object_id: None, line: None, column: None, path: None, span: None, related: vec![], }] } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct TransactionRequest { commands: Vec, #[serde(default)] dry_run: bool, } async fn transactions( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: TransactionRequest = json_body(&body)?; check_commands(&request.commands)?; let snapshot = store.checked(id, &expected)?; let current = &snapshot; let candidate = oa_core::prepare_command(¤t.model, &Command::Batch(request.commands)) .map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "command_rejected", error.to_string()))?; let prepared = PreparedProject::from_command(current, candidate)?; if request.dry_run { return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":prepared.changed(current),"issues":prepared.source_issues(),"model":prepared.model(),"source_project":prepared.bundle()}))); } let (stored, changed) = store.replace(id, &expected, prepared)?; Ok(response(&stored, StatusCode::OK, json!({"revision":stored.revision,"dry_run":false,"changed":changed,"issues":issues(&stored.model)}))) }).await } fn check_commands(commands: &[Command]) -> ApiResult<()> { let mut pending: Vec<_> = commands.iter().collect(); let mut count = 0; while let Some(command) = pending.pop() { count += 1; if count > 200 { return Err(ApiError::bad_request( "A transaction may contain at most 200 commands, including nested batches.", )); } if let Command::Batch(batch) = command { pending.extend(batch); } if let Command::Replace(model) = command { check_model_size(model)?; } } if count == 0 { return Err(ApiError::bad_request( "A transaction requires at least one command.", )); } Ok(()) } fn check_transition_request(request: &oa_core::transitions::TransitionRequest) -> ApiResult<()> { if request.sources.len() > 100 || request.mappings.len() > 200 || request.communication_endpoints.len() > 200 || request.operation_omissions.len() > 200 || request.resolutions.len() > 400 { return Err(ApiError::bad_request( "Transition requests allow at most 100 sources, 200 mappings, communication endpoint decisions or operation omissions and 400 field resolutions.", )); } Ok(()) } async fn preview_transition( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: oa_core::transitions::TransitionRequest = json_body(&body)?; check_transition_request(&request)?; let snapshot = store.checked(id, &expected)?; let current = &snapshot; let plan = oa_core::transitions::plan_transition(¤t.model, &request).map_err(|error| { ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string(), ) })?; if !plan.commands.is_empty() { check_commands(&plan.commands)?; } if plan.conflicts.is_empty() { let candidate = oa_core::transitions::prepare_transition(¤t.model, &plan) .map_err(|error| { ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string(), ) })?; PreparedProject::from_command(current, candidate)?; } Ok(response( current, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan}), )) }) .await } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct TransitionApplyRequest { base_revision: u64, plan: oa_core::transitions::TransitionPlan, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct SemanticQueryRequest { traces: Vec, #[serde(default)] references: BTreeMap, revision: Option, } fn semantic_rejected(error: impl std::fmt::Display) -> ApiError { ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, "semantic_transition_rejected", error.to_string(), ) } async fn query_semantic_transitions( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: SemanticQueryRequest = json_body(&body)?; if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 { return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings.")); } let selected: BTreeSet<_> = request.traces.iter().copied().collect(); if selected.len() != request.traces.len() { return Err(ApiError::bad_request("Semantic query trace IDs must be unique.")); } let current = store.get(id)?; check_read_revision(¤t, request.revision)?; let mut items = Vec::new(); for trace_id in request.traces { let trace = current.model.traces.get(&trace_id).ok_or_else(|| { ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.") })?; let facets = oa_core::semantic::semantic_facets(¤t.model, trace, &request.references) .map_err(semantic_rejected)?; let drift = oa_core::semantic::trace_semantic_drift(¤t.model, trace, &request.references) .map_err(semantic_rejected)?; let linked_impacts = oa_core::semantic::linked_semantic_impacts(¤t.model, trace, &request.references) .map_err(semantic_rejected)?; items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts})); } Ok(response(¤t, StatusCode::OK, json!({"revision":current.revision,"items":items}))) }).await } fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> { let transition = &request.transition; let decisions = request.reviews.iter().try_fold(0usize, |count, review| { count .checked_add(review.resolutions.len())? .checked_add(review.reset.len()) }); if transition.sources.len() > 100 || transition.mappings.len() > 200 || transition.communication_endpoints.len() > 200 || transition.operation_omissions.len() > 200 || transition.resolutions.len() > 400 || request.traces.len() > 100 || request.references.len() > 5_000 || request.reviews.len() > 100 || decisions.is_none_or(|count| count > 5_000) || request.candidates.len() > 200 || request.accepted_impacts.len() > 200 { return Err(ApiError::bad_request( "Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/communication endpoint decisions/operation omissions/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.", )); } Ok(()) } async fn preview_semantic_transition( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?; check_semantic_request(&request)?; let current = store.checked(id, &expected)?; let plan = oa_core::semantic::plan_semantic_transition(¤t.model, request) .map_err(semantic_rejected)?; if !plan.commands.is_empty() { check_commands(&plan.commands)?; } if plan.conflicts.is_empty() { let candidate = oa_core::semantic::prepare_semantic_transition(¤t.model, &plan) .map_err(semantic_rejected)?; PreparedProject::from_command(¤t, candidate)?; } Ok(response( ¤t, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan}), )) }) .await } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct SemanticApplyRequest { base_revision: u64, plan: oa_core::semantic::SemanticTransitionPlan, } async fn commit_semantic_transition( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: SemanticApplyRequest = json_body(&body)?; let current = store.checked(id, &expected)?; if current.revision != request.base_revision { let mut error = ApiError::new( StatusCode::PRECONDITION_FAILED, "plan_revision_conflict", "Semantic preview revision changed. Create a new preview.", ); error.current_revision = Some(current.revision); return Err(error); } check_semantic_request(&request.plan.request)?; if !request.plan.commands.is_empty() { check_commands(&request.plan.commands)?; } let candidate = oa_core::semantic::prepare_semantic_transition(¤t.model, &request.plan) .map_err(semantic_rejected)?; let prepared = PreparedProject::from_command(¤t, candidate)?; let (stored, changed) = store.replace(id, &expected, prepared)?; Ok(response( &stored, StatusCode::OK, json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}), )) }) .await } async fn commit_transition( State(state): State, id: ProjectPath, headers: HeaderMap, body: Body, ) -> ApiResult { let id = path(id)?; let expected = expected(&headers)?; media(&headers, "application/json")?; let body = bytes(body)?; blocking(state, move |store| { let request: TransitionApplyRequest = json_body(&body)?; check_transition_request(&request.plan.request)?; let snapshot = store.checked(id, &expected)?; let current = &snapshot; if current.revision != request.base_revision { let mut error = ApiError::new( StatusCode::PRECONDITION_FAILED, "plan_revision_conflict", "Transition preview revision changed. Create a new preview.", ); error.current_revision = Some(current.revision); return Err(error); } if !request.plan.commands.is_empty() { check_commands(&request.plan.commands)?; } let candidate = oa_core::transitions::prepare_transition(¤t.model, &request.plan) .map_err(|error| { ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string(), ) })?; let prepared = PreparedProject::from_command(current, candidate)?; let (stored, changed) = store.replace(id, &expected, prepared)?; Ok(response( &stored, StatusCode::OK, json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}), )) }) .await }