Files
ARCADIA-Studio/crates/oa-server/src/routes.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00

1340 lines
45 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 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<Path<Id>, PathRejection>;
type Body = Result<Bytes, BytesRejection>;
pub(crate) fn router() -> Router<AppState> {
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<Value> {
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<Id> {
value
.map(|Path(id)| id)
.map_err(|error| ApiError::bad_request(error.body_text()))
}
fn query<T>(value: Result<Query<T>, QueryRejection>) -> ApiResult<T> {
value
.map(|Query(value)| value)
.map_err(|error| ApiError::bad_request(error.body_text()))
}
fn bytes(value: Body) -> ApiResult<Bytes> {
value.map_err(|error| ApiError::new(error.status(), "invalid_body", error.body_text()))
}
fn json_body<T: DeserializeOwned>(body: &[u8]) -> ApiResult<T> {
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<String> {
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<Response> {
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<oa_core::persistence::DecodeResult> {
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<oa_core::persistence::DecodeResult> {
// 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<AppState>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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<Response> {
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<AppState>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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<AppState>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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<usize>,
after: Option<Id>,
q: Option<String>,
}
impl Page {
fn limit(&self) -> ApiResult<usize> {
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<AppState>,
pagination: Result<Query<Page>, QueryRejection>,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(
&current.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<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
replace_model(state, id, headers, body, true).await
}
async fn put_source(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
replace_model(state, id, headers, body, false).await
}
async fn replace_model(
state: AppState,
id: ProjectPath,
headers: HeaderMap,
body: Body,
is_json: bool,
) -> ApiResult<Response> {
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<usize>,
after: Option<Id>,
revision: Option<u64>,
kind: Option<Kind>,
layer: Option<Layer>,
alias: Option<String>,
q: Option<String>,
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RelationQuery {
limit: Option<usize>,
after: Option<Id>,
revision: Option<u64>,
kind: Option<RelationKind>,
source: Option<Id>,
target: Option<Id>,
incident_to: Option<Id>,
}
fn check_read_revision(stored: &StoredProject, revision: Option<u64>) -> 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<AppState>,
id: ProjectPath,
parameters: Result<Query<ElementQuery>, QueryRejection>,
) -> ApiResult<Response> {
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<AppState>,
id: ProjectPath,
parameters: Result<Query<RelationQuery>, QueryRejection>,
) -> ApiResult<Response> {
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<Id>,
#[serde(default)]
direction: Direction,
#[serde(default)]
relation_kinds: Vec<RelationKind>,
#[serde(default = "default_depth")]
max_depth: usize,
#[serde(default = "default_objects")]
max_objects: usize,
revision: Option<u64>,
}
fn default_depth() -> usize {
1
}
fn default_objects() -> usize {
200
}
async fn neighborhood(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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<Id, Vec<(Id, Id)>> = 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(&current).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::<Vec<_>>(),
"relations":objects.iter().filter_map(|id| stored.model.relations.get(id)).collect::<Vec<_>>(),"truncated":truncated})))
}).await
}
fn endpoint_closure(model: &Project, root: Id, objects: &mut BTreeSet<Id>) {
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<usize>,
after: Option<Id>,
revision: Option<u64>,
object: Option<Id>,
}
async fn list_artifacts(
State(state): State<AppState>,
id: ProjectPath,
parameters: Result<Query<LinkQuery>, QueryRejection>,
) -> ApiResult<Response> {
list_links(state, id, parameters, true).await
}
async fn list_traces(
State(state): State<AppState>,
id: ProjectPath,
parameters: Result<Query<LinkQuery>, QueryRejection>,
) -> ApiResult<Response> {
list_links(state, id, parameters, false).await
}
async fn list_links(
state: AppState,
id: ProjectPath,
parameters: Result<Query<LinkQuery>, QueryRejection>,
artifacts: bool,
) -> ApiResult<Response> {
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<AppState>, id: ProjectPath) -> ApiResult<Response> {
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<Box<serde_json::value::RawValue>>,
source: Option<String>,
source_project: Option<SourceBundle>,
}
async fn validate_candidate(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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<IssueDto> {
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<Command>,
#[serde(default)]
dry_run: bool,
}
async fn transactions(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(&current.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<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(&current.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(&current.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<Id>,
#[serde(default)]
references: BTreeMap<Id, Id>,
revision: Option<u64>,
}
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<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(&current, 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(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let drift = oa_core::semantic::trace_semantic_drift(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let linked_impacts = oa_core::semantic::linked_semantic_impacts(&current.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(&current, 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<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(&current.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(&current.model, &plan)
.map_err(semantic_rejected)?;
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 SemanticApplyRequest {
base_revision: u64,
plan: oa_core::semantic::SemanticTransitionPlan,
}
async fn commit_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(&current.model, &request.plan)
.map_err(semantic_rejected)?;
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
}
async fn commit_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
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(&current.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
}