Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
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.
1340 lines
45 KiB
Rust
1340 lines
45 KiB
Rust
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(
|
||
¤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<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(¤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::<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(¤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<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(¤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<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(¤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<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(¤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<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(¤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<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(¤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
|
||
}
|