feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
+131 -34
View File
@@ -1,7 +1,10 @@
use crate::{
AppState, blocking,
error::{ApiError, ApiResult, IssueDto, issues},
store::{MODEL_LIMITS, StoredProject, check_model_size, validate_model},
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,
@@ -14,6 +17,7 @@ use axum::{
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};
@@ -31,6 +35,10 @@ pub(crate) fn router() -> Router<AppState> {
.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)
@@ -42,6 +50,10 @@ pub(crate) fn router() -> Router<AppState> {
"/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))
@@ -209,6 +221,9 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.object_id,
line: Some(issue.line),
column: Some(issue.column),
path: None,
span: Some(issue.span.into()),
related: vec![],
})
.collect();
error
@@ -236,6 +251,9 @@ fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect();
error
@@ -306,6 +324,20 @@ async fn import_project(
.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 {
@@ -377,14 +409,14 @@ async fn rename_project(
let request: NameRequest = json_body(&body)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(
let prepared = oa_core::prepare_command(
&current.model,
&[Command::EditProject {
&Command::EditProject {
id: current.model.id,
alias: current.model.alias.clone(),
name: request.name,
description: current.model.description.clone(),
}],
},
)
.map_err(|error| {
ApiError::new(
@@ -393,7 +425,8 @@ async fn rename_project(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, model)?;
let prepared = PreparedProject::from_command(current, prepared)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
@@ -457,6 +490,48 @@ async fn get_source(
.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
}),
))
})
.await
}
async fn put_model(
State(state): State<AppState>,
id: ProjectPath,
@@ -504,7 +579,7 @@ async fn replace_model(
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration}),
))
})
.await
@@ -844,8 +919,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
#[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(
@@ -861,31 +939,31 @@ async fn validate_candidate(
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) {
("json", Some(model), None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source)) => parse_source(source.as_bytes()),
_ => return Err(ApiError::bad_request("Validation requires either format json with only model, or format arcadia with only source.")),
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();
if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?;
}
Ok(diagnostics)
});
let diagnostics = result.unwrap_or_else(candidate_issues);
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
}
_ => 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) => {
let mut diagnostics = error.issues;
if diagnostics.is_empty() {
diagnostics.push(IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
});
}
(false, diagnostics, None)
}
Err(error) => (false, candidate_issues(error), None),
};
Ok(response(
stored,
@@ -896,6 +974,24 @@ async fn validate_candidate(
.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 {
@@ -919,13 +1015,13 @@ async fn transactions(
check_commands(&request.commands)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(&current.model, &request.commands)
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()))?;
validate_model(&model)?;
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":model != current.model,"issues":issues(&model),"model":model})));
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, model)?;
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
}
@@ -976,9 +1072,9 @@ async fn preview_transition(
.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::apply_transition(&current.model, &plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &plan)
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
validate_model(&candidate)?;
PreparedProject::from_command(current, candidate)?;
}
Ok(response(current, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan})))
}).await
@@ -1017,7 +1113,7 @@ async fn commit_transition(
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate = oa_core::transitions::apply_transition(&current.model, &request.plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &request.plan)
.map_err(|error| {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -1025,7 +1121,8 @@ async fn commit_transition(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, candidate)?;
let prepared = PreparedProject::from_command(current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,