feat: preserve nested source projects across desktop and REST
This commit is contained in:
+131
-34
@@ -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(
|
||||
¤t.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(¤t.model, &request.commands)
|
||||
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()))?;
|
||||
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(¤t.model, &plan)
|
||||
let candidate = oa_core::transitions::prepare_transition(¤t.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(¤t.model, &request.plan)
|
||||
let candidate = oa_core::transitions::prepare_transition(¤t.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,
|
||||
|
||||
Reference in New Issue
Block a user