Migrate schema 2 ports through frozen readers and expose schema 3 source API
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-09-05 18:23:33 +03:00
parent 0d14438b7b
commit b9f88ce1f3
21 changed files with 7513 additions and 152 deletions
+56 -35
View File
@@ -182,13 +182,13 @@ fn not_modified(stored: &StoredProject, headers: &HeaderMap) -> Option<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()})
"elements":stored.model.elements.len(),"relations":stored.model.relations.len(),"migration":stored.migration})
}
fn parse_source(body: &[u8]) -> ApiResult<Project> {
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_limited(source, MODEL_LIMITS).map_err(|diagnostics| {
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 {
@@ -215,22 +215,41 @@ fn parse_source(body: &[u8]) -> ApiResult<Project> {
})
}
fn import_json(body: &[u8]) -> ApiResult<Project> {
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 migration.
fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration.
let _: Value = json_body(body)?;
oa_core::persistence::decode_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::Json(error) => {
ApiError::bad_request(format!("Invalid model JSON: {error}"))
}
error => ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_model",
error.to_string(),
),
})
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,
})
.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)]
@@ -282,7 +301,7 @@ async fn import_project(
} else {
parse_source(&body)?
};
created(store.create(model)?)
created(store.create_decoded(model)?)
})
.await
}
@@ -477,15 +496,15 @@ async fn replace_model(
blocking(state, move |store| {
store.checked(id, &expected)?;
let model = if is_json {
json_body(&body)?
import_json(&body)?
} else {
parse_source(&body)?
};
let (stored, changed) = store.replace(id, &expected, model)?;
let (stored, changed) = store.replace_decoded(id, &expected, model)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
))
})
.await
@@ -822,10 +841,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
}
#[derive(Deserialize)]
#[serde(tag = "format", rename_all = "snake_case", deny_unknown_fields)]
enum ValidationRequest {
Json { model: Project },
Arcadia { source: String },
#[serde(deny_unknown_fields)]
struct ValidationRequest {
format: String,
model: Option<Box<serde_json::value::RawValue>>,
source: Option<String>,
}
async fn validate_candidate(
@@ -841,15 +861,16 @@ async fn validate_candidate(
let snapshot = store.get(id)?;
let stored = &snapshot;
let request: ValidationRequest = json_body(&body)?;
let parsed = match request {
ValidationRequest::Json { model } => Ok(model),
ValidationRequest::Arcadia { source } => parse_source(source.as_bytes()),
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 (valid, issues) = match parsed.and_then(|model| {
validate_model(&model)?;
Ok(model)
let (valid, issues, migration) = match parsed.and_then(|decoded| {
validate_model(&decoded.project)?;
Ok(decoded)
}) {
Ok(model) => (true, issues(&model)),
Ok(decoded) => (true, issues(&decoded.project), decoded.migration),
Err(error) => {
let mut diagnostics = error.issues;
if diagnostics.is_empty() {
@@ -863,13 +884,13 @@ async fn validate_candidate(
column: None,
});
}
(false, diagnostics)
(false, diagnostics, None)
}
};
Ok(response(
stored,
StatusCode::OK,
json!({"revision":stored.revision,"valid":valid,"issues":issues}),
json!({"revision":stored.revision,"valid":valid,"issues":issues,"migration":migration}),
))
})
.await