Merge branch 'wt/review-002-projection-truthfulness'
This commit is contained in:
+470
-97
@@ -18,8 +18,19 @@
|
||||
//! the doc with the settled gateway runtime contract (review-001
|
||||
//! PRJ-01..05, PRJ-14, PRJ-15): envelope responses, 422 client-fault
|
||||
//! mappings, the `/subscribe` 200+SSE asymmetry, Bearer
|
||||
//! `securitySchemes`, shared error components — additive; no
|
||||
//! previously-documented status or field was removed.
|
||||
//! `securitySchemes`, shared error components. `1.3.0` is the
|
||||
//! review-002 projection-truthfulness pass (PRJ-16b/17/18/19/20/21/23):
|
||||
//! runtime-resolvable refs (the in-band `BatchResultEntry.error`
|
||||
//! component), protocol-status op errors merged oneOf into the shared
|
||||
//! responses instead of clobbering them, `/call` 401 and the extractor
|
||||
//! 415/plain-text-422 slots documented, `/publish` 400 narrowed to the
|
||||
//! framing contract, the unreachable `/batch` 500 removed, and the
|
||||
//! OAS-invalid `x-operation-error-statuses` pseudo-schema dropped. Per
|
||||
//! ADR-045's tracking rule this is a minor bump, not major: the
|
||||
//! corrections remove only documentation of statuses/codes the runtime
|
||||
//! never emitted (a strict client matching them observed nothing to
|
||||
//! break), and every added slot documents behavior the runtime already
|
||||
//! had — the wire contract is unchanged.
|
||||
//!
|
||||
//! # Error fidelity
|
||||
//!
|
||||
@@ -28,25 +39,28 @@
|
||||
//!
|
||||
//! - `INVALID_INPUT` and identity-resolved `INVALID_OPERATION_TYPE` are
|
||||
//! documented at `422` — the status the runtime emits. Axum extractor
|
||||
//! rejections (malformed JSON body, wrong shape) are plain-text
|
||||
//! 400/415/422 bodies with none of the `CallError` JSON shape; the doc
|
||||
//! documents the dispatch-path contract and notes the extractor gap
|
||||
//! per response (the PRJ-03 doc-align decision; closing the gap is a
|
||||
//! runtime change out of scope here).
|
||||
//! rejections are plain-text bodies with none of the `CallError` JSON
|
||||
//! shape: malformed framing answers `400`, a missing or non-JSON
|
||||
//! `Content-Type` answers `415`, and a syntactically-valid body that
|
||||
//! fails deserialization answers `422` (axum's `Json`/`Query`
|
||||
//! rejection statuses — PRJ-19). Those slots are documented alongside
|
||||
//! the dispatch-path JSON contract per endpoint.
|
||||
//! - Protocol-level codes are documented at their mapped statuses.
|
||||
//! `FORBIDDEN`/`INVALID_OPERATION_TYPE` map to `401` (no token) or
|
||||
//! `403`/`422` (token present) per the identity-aware mapper; the doc
|
||||
//! places them at the identity-resolved status.
|
||||
//! places them at the identity-resolved status, so the `401` slots
|
||||
//! carry both codes (PRJ-20).
|
||||
//! - Operation-level codes are projected by registry-declared
|
||||
//! `http_status`, **but** the runtime is purely code-driven: a
|
||||
//! non-`HTTP_*` code (e.g. `RATE_LIMITED` declared at 429) actually
|
||||
//! surfaces as `500 INTERNAL` because `ErrorDefinition.http_status` is
|
||||
//! never consulted at dispatch. The doc annotates every such
|
||||
//! projection with `x-runtime-behavior: 500` (the PRJ-04
|
||||
//! project-honest decision; honoring `http_status` at runtime was
|
||||
//! rejected as a runtime contract change). `HTTP_<status>`-prefixed
|
||||
//! codes surface at their declared status at runtime and carry no
|
||||
//! annotation.
|
||||
//! never consulted at dispatch. Non-protocol statuses carry
|
||||
//! `x-runtime-behavior: 500` (the PRJ-04 project-honest decision);
|
||||
//! protocol-status declarations from `HTTP_<status>`-prefixed codes
|
||||
//! are **merged oneOf** into the shared protocol response instead of
|
||||
//! overwriting it (PRJ-17) — the runtime genuinely maps those codes
|
||||
//! to the declared status, alongside the protocol codes that status
|
||||
//! already documents.
|
||||
//!
|
||||
//! See `docs/architecture/http-adapters.md` §"to_openapi" and
|
||||
//! ADR-042/045/068/023.
|
||||
@@ -62,7 +76,7 @@ use alkcall::registry::spec::ErrorDefinition;
|
||||
use super::openapi_spec::OpenAPISpec;
|
||||
use crate::gateway::MAX_BATCH_OPERATIONS;
|
||||
|
||||
const GATEWAY_VERSION: &str = "1.2.0";
|
||||
const GATEWAY_VERSION: &str = "1.3.0";
|
||||
const GATEWAY_TITLE: &str = "alk gateway";
|
||||
const OPENAPI_VERSION: &str = "3.0.0";
|
||||
|
||||
@@ -90,6 +104,9 @@ const CODE_NOT_FOUND: &str = "NOT_FOUND";
|
||||
const CODE_INTERNAL: &str = "INTERNAL";
|
||||
const CODE_TIMEOUT: &str = "TIMEOUT";
|
||||
|
||||
const SCHEMA_BATCH_ERROR: &str = "BatchError";
|
||||
const SCHEMA_BATCH_OPERATION_ERROR: &str = "BatchOperationError";
|
||||
|
||||
const SCHEME_BEARER: &str = "bearerAuth";
|
||||
|
||||
const RESPONSE_REF: &str = "#/components/schemas/";
|
||||
@@ -140,6 +157,9 @@ fn search_path_item() -> Value {
|
||||
"responses": {
|
||||
"200": json_response(ref_schema("SearchResponse"),
|
||||
"The AccessControl-filtered operation listing under `output` (GW-02). Items carry name, namespace, and op_type; forbidden ops are omitted from the listing — the request does not fail. Cache-Control: no-store, Vary: Authorization."),
|
||||
"400": plain_text_extractor_rejection(),
|
||||
"415": plain_text_unsupported_media_type(),
|
||||
"422": plain_text_extractor_shape_rejection(),
|
||||
"404": json_response(ref_response("NotFound"),
|
||||
"The appended /search and /schema reserved paths are hidden from the listing; a direct dispatch to services/list still resolves normally."),
|
||||
"500": json_response(ref_response("Internal"),
|
||||
@@ -169,6 +189,7 @@ fn schema_path_item() -> Value {
|
||||
"200": json_response(ref_schema("SchemaResponse"),
|
||||
"The operation's full spec under `output`. Cache-Control: no-store, Vary: Authorization."),
|
||||
"400": plain_text_extractor_rejection(),
|
||||
"415": plain_text_unsupported_media_type(),
|
||||
"401": json_response(ref_response("Unauthorized"),
|
||||
"No bearer token or it did not resolve; AccessControl checks FORBIDDEN (401 without a token)."),
|
||||
"403": json_response(ref_response("Forbidden"),
|
||||
@@ -176,7 +197,7 @@ fn schema_path_item() -> Value {
|
||||
"404": json_response(ref_response("NotFound"),
|
||||
"Unknown operation, or the operation is Internal (hidden from HTTP discovery, GW-02)."),
|
||||
"422": json_response(ref_response("InvalidInput"),
|
||||
"Dispatch-path client fault: the services/schema op reported INVALID_INPUT (missing name)."),
|
||||
"Dispatch-path client fault: the services/schema op reported INVALID_INPUT (missing name). A query string that parses but yields no `name` is a plain-text extractor 422 (PRJ-19)."),
|
||||
"500": json_response(ref_response("Internal"),
|
||||
"Dispatcher failure."),
|
||||
"504": json_response(ref_response("Timeout"),
|
||||
@@ -205,6 +226,62 @@ fn call_path_item(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The operation-declared status projections merged into a target
|
||||
/// responses map (PRJ-17): the shared protocol statuses document the
|
||||
/// protocol codes the runtime emits at that status, and the runtime
|
||||
/// also maps `HTTP_<status>`-prefixed operation codes to their declared
|
||||
/// status — so instead of overwriting a shared response (which would
|
||||
/// erase the protocol codes every real call still carries), each
|
||||
/// operation-declared projection is appended to the shared response's
|
||||
/// `oneOf` as a per-code envelope component. Non-protocol statuses have
|
||||
/// nothing shared to merge with; they overwrite as before (they only
|
||||
/// occur at keys the fixed map has not set).
|
||||
fn merge_operation_errors(responses: &mut Value, operation_errors: &BTreeMap<u16, Value>) {
|
||||
for (status, projection) in operation_errors {
|
||||
let key = status.to_string();
|
||||
let code_variants: Vec<Value> = projection
|
||||
.pointer("/content/application~1json/schema/properties/code/enum")
|
||||
.and_then(Value::as_array)
|
||||
.map(|codes| {
|
||||
codes
|
||||
.iter()
|
||||
.filter_map(|code| code.as_str())
|
||||
.map(error_variant_ref)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if code_variants.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(shared) = responses.get_mut(&key) else {
|
||||
responses[&key] = projection.clone();
|
||||
continue;
|
||||
};
|
||||
let Some(shared_schema) = shared
|
||||
.pointer_mut("/content/application~1json/schema")
|
||||
.filter(|schema| schema.is_object())
|
||||
else {
|
||||
responses[&key] = projection.clone();
|
||||
continue;
|
||||
};
|
||||
let existing = shared_schema.as_object_mut().expect("schema object");
|
||||
if !existing.contains_key("oneOf") {
|
||||
let shared_schema_value = existing.clone();
|
||||
existing.insert("oneOf".to_string(), json!([shared_schema_value]));
|
||||
}
|
||||
if let Some(variants) = existing.get_mut("oneOf").and_then(Value::as_array_mut) {
|
||||
variants.extend(code_variants);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-envelope component name of one operation-declared error code
|
||||
/// (PRJ-17's merge target): the oneOf variant a merged protocol
|
||||
/// response references.
|
||||
fn error_variant_ref(code: &str) -> Value {
|
||||
json!({ "$ref": format!("{RESPONSE_REF}CallError_{code}") })
|
||||
}
|
||||
|
||||
/// The fixed status set of a dispatch-path endpoint's documented
|
||||
/// response map (200 + protocol statuses, plus the operation-declared
|
||||
/// statuses gathered from the registry; PRJ-12 determinism: the merge
|
||||
@@ -214,17 +291,27 @@ fn call_responses(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
"200": json_response(ref_schema("CallOk"),
|
||||
"The operation's final ResponseEnvelope: request_id, result=ok, output."),
|
||||
"400": plain_text_extractor_rejection(),
|
||||
"401": json_response(ref_response("Unauthorized"),
|
||||
"No bearer token or it did not resolve (AccessControl denial without identity)."),
|
||||
"415": plain_text_unsupported_media_type(),
|
||||
"401": json_response(one_of_refs(&[
|
||||
"CallErrorForbidden".to_string(),
|
||||
"CallErrorInvalidOperationType".to_string(),
|
||||
]),
|
||||
"No bearer token or it did not resolve: AccessControl denial (FORBIDDEN) or the non-Once-op dispatch-path report (INVALID_OPERATION_TYPE) without identity (PRJ-20). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape)."),
|
||||
"403": json_response(ref_response("Forbidden"),
|
||||
"Token resolved, but AccessControl denies the operation."),
|
||||
"404": json_response(ref_response("NotFound"),
|
||||
"Unknown operation, or the operation is Internal (hidden from the HTTP surface)."),
|
||||
"422": json_response(one_of_refs(&[
|
||||
"CallErrorInvalidInput".to_string(),
|
||||
"CallErrorInvalidOperationType".to_string(),
|
||||
]),
|
||||
"Dispatch-path client fault: malformed input (INVALID_INPUT), or the operation's type does not accept a Once invoke (INVALID_OPERATION_TYPE). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape). Once-op invokes are bounded by the 30 s gateway deadline."),
|
||||
"422": json_response(
|
||||
{
|
||||
let mut schema = one_of_refs(&[
|
||||
"CallErrorInvalidInput".to_string(),
|
||||
"CallErrorInvalidOperationType".to_string(),
|
||||
]);
|
||||
schema["x-extractor-variant"] = json!("A syntactically valid JSON body that fails deserialization is also answered 422, with a plain-text extractor body (PRJ-19) — see components.responses.ExtractorShapeRejection.");
|
||||
schema
|
||||
},
|
||||
"Dispatch-path client fault: malformed input (INVALID_INPUT), or the operation's type does not accept a Once invoke (INVALID_OPERATION_TYPE). The extractor's shape rejection (valid JSON, wrong shape) is also a 422 but with a plain-text body, not this envelope (PRJ-19). Once-op invokes are bounded by the 30 s gateway deadline.",
|
||||
),
|
||||
"500": json_response(one_of_refs(&[
|
||||
"CallErrorInternal".to_string(),
|
||||
"CallFailure".to_string(),
|
||||
@@ -233,9 +320,7 @@ fn call_responses(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
"504": json_response(ref_response("Timeout"),
|
||||
"The Once-op dispatch exceeded the 30 s gateway deadline (retryable)."),
|
||||
});
|
||||
for (status, projection) in operation_errors {
|
||||
responses[&status.to_string()] = projection.clone();
|
||||
}
|
||||
merge_operation_errors(&mut responses, operation_errors);
|
||||
responses
|
||||
}
|
||||
|
||||
@@ -266,9 +351,8 @@ fn publish_responses(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
"The operation's final ResponseEnvelope: request_id, result=ok, output."),
|
||||
"400": json_response(one_of_refs(&[
|
||||
"CallErrorInvalidInput".to_string(),
|
||||
"CallErrorInvalidOperationType".to_string(),
|
||||
]),
|
||||
"Malformed NDJSON stream framing: empty body, first line missing 'operation'/'chunk', a line exceeding the 2 MiB per-line cap (INVALID_INPUT), or a non-Pub op reported INVALID_OPERATION_TYPE without a resolved token (see 422 for the token-present variant). Raw read failures surface as INVALID_INPUT too."),
|
||||
"Malformed NDJSON stream framing: empty body, first line missing 'operation'/'chunk', a line exceeding the 2 MiB per-line cap, or a raw body-read failure (INVALID_INPUT). A later NDJSON line that is not valid JSON is a dispatch-path 422. The gateway's 2 MiB + 64 KiB body-limit layer answers an oversized whole-body upload with a plain-text 413 before the route runs. A non-Pub op without a resolved token is the 401 below (INVALID_OPERATION_TYPE), not this status (PRJ-18)."),
|
||||
"401": json_response(one_of_refs(&[
|
||||
"CallErrorForbidden".to_string(),
|
||||
"CallErrorInvalidOperationType".to_string(),
|
||||
@@ -291,12 +375,7 @@ fn publish_responses(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
"504": json_response(ref_response("Timeout"),
|
||||
"The Once-op final envelope dispatch exceeded the 30 s gateway deadline (retryable)."),
|
||||
});
|
||||
for (status, projection) in operation_errors {
|
||||
if status == &STATUS_BAD_REQUEST {
|
||||
continue;
|
||||
}
|
||||
responses[&status.to_string()] = projection.clone();
|
||||
}
|
||||
merge_operation_errors(&mut responses, operation_errors);
|
||||
responses
|
||||
}
|
||||
|
||||
@@ -320,16 +399,11 @@ fn batch_path_item() -> Value {
|
||||
},
|
||||
"responses": {
|
||||
"200": json_response(ref_schema("BatchResponse"),
|
||||
"results[] shares entries' order with the request; each entry is an envelope-shaped {request_id, result, output|error} object; entries for Internal ops carry a NOT_FOUND in-band error."),
|
||||
"results[] shares entries' order with the request; each entry is an envelope-shaped {request_id, result, output|error} object; entries for Internal ops carry a NOT_FOUND in-band error. Per-call dispatch failures surface only as these in-band entries — there is no HTTP error status for an individual call."),
|
||||
"400": json_response(one_of_refs(&[
|
||||
"BatchCapExceeded".to_string(),
|
||||
]),
|
||||
"Request-level failure: the batch exceeds 100 operations (INVALID_INPUT, JSON). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape)."),
|
||||
"500": json_response(one_of_refs(&[
|
||||
"CallErrorInternal".to_string(),
|
||||
"CallFailure".to_string(),
|
||||
]),
|
||||
"Dispatcher failure, or an operation-level error code without HTTP_ prefix or http_status (the runtime mapper is purely code-driven and such codes surface as 500)."),
|
||||
"Request-level failure: the batch exceeds 100 operations (INVALID_INPUT, JSON). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape); a JSON array body whose items fail deserialization is a plain-text extractor 422. Oversized uploads are pre-empted by the gateway's body-limit layer with a plain-text 413."),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -353,6 +427,8 @@ fn subscribe_path_item() -> Value {
|
||||
"200": sse_response(
|
||||
"Server-Sent Events. Each Success SSE event carries one output value (with a retry: 15000 reconnect hint); an Error event carries the serialized CallError as its data and ends the stream (an Err is terminal — no events follow it, matching the wire dispatch's call.error semantics). Keep-alive comment frames are sent every 15 s. Pre-dispatch denials (unknown op, Internal op, ACL denial, wrong dispatch path), handler-triggered (not stream) failures and mid-stream handler failures all appear as event:error frames here — standard HTTP monitoring sees no failures on /subscribe; clients must inspect event:error."),
|
||||
"400": plain_text_extractor_rejection(),
|
||||
"415": plain_text_unsupported_media_type(),
|
||||
"422": plain_text_extractor_shape_rejection(),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -392,6 +468,8 @@ fn components(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
"OperationSpecOutput": operation_spec_output_schema(),
|
||||
"BatchResponse": batch_response_schema(),
|
||||
"BatchResultEntry": batch_result_entry_schema(),
|
||||
"BatchError": batch_error_schema(),
|
||||
"BatchOperationError": error_variant_schema(&operation_error_codes(operation_errors)),
|
||||
"BatchCapExceeded": batch_cap_exceeded_schema(),
|
||||
"SseStream": sse_stream_schema(),
|
||||
"NdjsonBody": ndjson_body_schema(),
|
||||
@@ -410,9 +488,11 @@ fn components(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
"CallErrorInternal": call_error_schema(CODE_INTERNAL),
|
||||
"CallErrorTimeout": call_error_schema(CODE_TIMEOUT)
|
||||
});
|
||||
if !operation_errors.is_empty() {
|
||||
schemas["x-operation-error-statuses"] =
|
||||
json!(operation_errors.keys().copied().collect::<Vec<u16>>());
|
||||
for code in operation_error_codes(operation_errors) {
|
||||
let component = format!("CallError_{code}");
|
||||
if schemas.get(&component).is_none() {
|
||||
schemas[&component] = call_error_schema(&code);
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"securitySchemes": {
|
||||
@@ -439,6 +519,26 @@ fn components(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The operation-declared codes, deduplicated and sorted across every
|
||||
/// status (BTreeMap-fold, PRJ-12 determinism): the code set the merged
|
||||
/// `CallError_<code>` components and `BatchOperationError`'s enum cover.
|
||||
fn operation_error_codes(operation_errors: &BTreeMap<u16, Value>) -> Vec<String> {
|
||||
let mut codes = std::collections::BTreeSet::new();
|
||||
for projection in operation_errors.values() {
|
||||
if let Some(enum_codes) = projection
|
||||
.pointer("/content/application~1json/schema/properties/code/enum")
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
codes.extend(
|
||||
enum_codes
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string)),
|
||||
);
|
||||
}
|
||||
}
|
||||
codes.into_iter().collect()
|
||||
}
|
||||
|
||||
fn one_of_response() -> Value {
|
||||
json_response(
|
||||
one_of_refs(&[
|
||||
@@ -498,6 +598,22 @@ fn call_error_schema(code: &str) -> Value {
|
||||
schema
|
||||
}
|
||||
|
||||
/// The generic in-band error variant: the `CallError` wire shape with
|
||||
/// `code` carrying the operation-declared enum (PRJ-16b). With no
|
||||
/// declared operation errors the arm collapses to the unpinned shape
|
||||
/// (still runtime-true: a foreign code surfaces as `500 INTERNAL`,
|
||||
/// whose envelope is exactly this shape).
|
||||
fn error_variant_schema(codes: &[String]) -> Value {
|
||||
let mut schema = call_error_shape_schema();
|
||||
if !codes.is_empty() {
|
||||
schema["properties"]["code"] = json!({
|
||||
"type": "string",
|
||||
"enum": codes
|
||||
});
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
fn failure_envelope_schema(operation_errors: &BTreeMap<u16, Value>) -> Value {
|
||||
let _ = operation_errors;
|
||||
json!({
|
||||
@@ -598,11 +714,31 @@ fn batch_result_entry_schema() -> Value {
|
||||
"request_id": { "type": "string" },
|
||||
"result": { "type": "string", "enum": ["ok", "error"] },
|
||||
"output": { "description": "The operation's output when result=ok." },
|
||||
"error": ref_schema("CallError")
|
||||
"error": ref_schema(SCHEMA_BATCH_ERROR)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The in-band error of a failed batch entry (PRJ-16b): each failed
|
||||
/// entry carries the serialized `CallError` — a protocol code (the six
|
||||
/// pinned components) or an operation-declared code (the generic arm's
|
||||
/// enum). An in-band payload is the raw error, not the 500-remap the
|
||||
/// HTTP status path applies, so the operation-code arm stays generic.
|
||||
fn batch_error_schema() -> Value {
|
||||
json!({
|
||||
"description": "The in-band error of one failed batch entry: the serialized CallError as the runtime emits it — any protocol or operation-declared code lands here.",
|
||||
"oneOf": [
|
||||
ref_schema("CallErrorInvalidInput"),
|
||||
ref_schema("CallErrorInvalidOperationType"),
|
||||
ref_schema("CallErrorForbidden"),
|
||||
ref_schema("CallErrorNotFound"),
|
||||
ref_schema("CallErrorInternal"),
|
||||
ref_schema("CallErrorTimeout"),
|
||||
ref_schema(SCHEMA_BATCH_OPERATION_ERROR)
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn batch_response_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -665,13 +801,47 @@ fn ref_response(name: &str) -> Value {
|
||||
/// rejection — NOT the `CallError` JSON envelope. The route extractors
|
||||
/// (`Json<CallRequest>`, `Json<Vec<CallRequest>>`, `Query<SchemaQuery>`)
|
||||
/// reject malformed request framing before dispatch; their bodies are
|
||||
/// plain text (axum default). This is a known runtime gap (PRJ-03
|
||||
/// doc-align): the JSON `CallError` shape is only guaranteed on the
|
||||
/// dispatch-path errors (422/401/403/404/500/504 and operation-declared
|
||||
/// statuses).
|
||||
/// plain text (axum default). The sibling slots
|
||||
/// ([`plain_text_unsupported_media_type`],
|
||||
/// [`plain_text_extractor_shape_rejection`]) document the 415 and 422
|
||||
/// rejections the same extractor layer emits (PRJ-19). These are known
|
||||
/// runtime gaps (PRJ-03 doc-align): the JSON `CallError` shape is only
|
||||
/// guaranteed on the dispatch-path errors (401/403/404/422/500/504 and
|
||||
/// operation-declared statuses).
|
||||
fn plain_text_extractor_rejection() -> Value {
|
||||
json!({
|
||||
"description": "Malformed request framing (bad JSON body / wrong shape / bad query string). NOTE: the framework's extractor rejects with a PLAIN-TEXT body (not the CallError JSON shape this doc uses elsewhere) — a known runtime gap (review-001 PRJ-03). Dispatch-path client faults use 422 with the JSON envelope instead.",
|
||||
"description": "Malformed request framing (syntactically invalid JSON body / malformed query string). NOTE: the framework's extractor rejects with a PLAIN-TEXT body (not the CallError JSON shape this doc uses elsewhere) — a known runtime gap (review-001 PRJ-03). Dispatch-path client faults use 422 with the JSON envelope instead. A body exceeding the gateway's request-body limit is a plain-text 413.",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A `415` response: the plain-text rejection emitted when the request
|
||||
/// carries no `Content-Type` (or a non-JSON one on a JSON-body
|
||||
/// endpoint) (PRJ-19). Same extractor layer as the 400/422 slots.
|
||||
fn plain_text_unsupported_media_type() -> Value {
|
||||
json!({
|
||||
"description": "Missing Content-Type (or a type the endpoint does not consume). The framework's extractor rejects with a PLAIN-TEXT body — not the CallError JSON shape (PRJ-19).",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A `422` response: the plain-text rejection emitted when the body is
|
||||
/// syntactically valid JSON but fails deserialization into the
|
||||
/// endpoint's request type (e.g. a JSON array where the endpoint reads
|
||||
/// one object, or a per-item shape mismatch on `/batch`) (PRJ-19).
|
||||
/// Dispatch-path 422s (invalid *input data*) use the JSON envelope
|
||||
/// instead.
|
||||
fn plain_text_extractor_shape_rejection() -> Value {
|
||||
json!({
|
||||
"description": "The body is valid JSON but does not deserialize into the endpoint's request shape. The framework's extractor rejects with a PLAIN-TEXT body — not the CallError JSON shape (PRJ-19).",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": { "type": "string" }
|
||||
@@ -896,7 +1066,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_version_is_1_2_0_after_runtime_truth_overhaul() {
|
||||
fn info_version_is_1_3_0_after_projection_truthfulness() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let version = spec
|
||||
@@ -906,8 +1076,8 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(version, GATEWAY_VERSION);
|
||||
assert_eq!(
|
||||
version, "1.2.0",
|
||||
"minor bump: the gateway shape only gains documented fields/statuses (ADR-045)"
|
||||
version, "1.3.0",
|
||||
"minor bump: doc-contract corrections only — the wire contract is unchanged (ADR-045)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -936,6 +1106,25 @@ mod tests {
|
||||
assert_eq!(spec.paths.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_with_operation_errors_validates_against_openapiv3() {
|
||||
let registry = sample_registry();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let text = serde_json::to_string(&spec.raw).unwrap();
|
||||
let parsed: openapiv3::OpenAPI =
|
||||
serde_json::from_str(&text).expect("populated doc parses as OpenAPI 3.0");
|
||||
let components = parsed.components.expect("components present");
|
||||
assert!(
|
||||
components
|
||||
.schemas
|
||||
.keys()
|
||||
.any(|name| name.starts_with("CallError_")),
|
||||
"merged operation-error components present: {:?}",
|
||||
components.schemas.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(spec.paths.len(), 6);
|
||||
}
|
||||
|
||||
// --- PRJ-15: securitySchemes / security -------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -1085,7 +1274,9 @@ mod tests {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_SCHEMA, "get");
|
||||
for status in ["200", "400", "401", "403", "404", "422", "500", "504"] {
|
||||
for status in [
|
||||
"200", "400", "401", "403", "404", "415", "422", "500", "504",
|
||||
] {
|
||||
assert!(
|
||||
responses.contains_key(status),
|
||||
"/schema {status} documented"
|
||||
@@ -1128,10 +1319,10 @@ mod tests {
|
||||
let r400 = responses_map.get("400").unwrap();
|
||||
let text = r400["content"]["text/plain"]["schema"]["type"].as_str();
|
||||
assert_eq!(text, Some("string"));
|
||||
let description = r400.get("description").and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
r400.pointer("/description").and_then(Value::as_str)
|
||||
== Some("Malformed request framing (bad JSON body / wrong shape / bad query string). NOTE: the framework's extractor rejects with a PLAIN-TEXT body (not the CallError JSON shape this doc uses elsewhere) — a known runtime gap (review-001 PRJ-03). Dispatch-path client faults use 422 with the JSON envelope instead."),
|
||||
"the extractor-rejection gap is documented on the 400 itself"
|
||||
description.contains("PLAIN-TEXT body"),
|
||||
"the extractor-rejection gap is documented on the 400 itself: {description}"
|
||||
);
|
||||
assert!(r400["content"].get("application/json").is_none());
|
||||
}
|
||||
@@ -1143,16 +1334,59 @@ mod tests {
|
||||
let responses = responses(&spec, PATH_BATCH, "post");
|
||||
assert!(responses.contains_key("400"));
|
||||
let schema = response_schema(responses.get("400").unwrap());
|
||||
let refs: Vec<&str> = schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.get("$ref").and_then(Value::as_str))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert!(refs.contains(&"#/components/schemas/BatchCapExceeded"));
|
||||
let refs = one_of_refs_of(schema);
|
||||
assert!(refs.contains(&"#/components/schemas/BatchCapExceeded".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_and_search_document_the_extractor_415_and_422_slots() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
for (path_name, method, statuses) in [
|
||||
(PATH_CALL, "post", vec!["400", "415"]),
|
||||
(PATH_SEARCH, "get", vec!["400", "415", "422"]),
|
||||
(PATH_SCHEMA, "get", vec!["400", "415"]),
|
||||
(PATH_SUBSCRIBE, "post", vec!["400", "415", "422"]),
|
||||
] {
|
||||
let responses_map = responses(&spec, path_name, method);
|
||||
for status in statuses {
|
||||
let slot = responses_map
|
||||
.get(status)
|
||||
.unwrap_or_else(|| panic!("{path_name} {status} documented (PRJ-19)"));
|
||||
let text_type = slot
|
||||
.get("content")
|
||||
.and_then(|c| c.get("text/plain"))
|
||||
.and_then(|c| c.get("schema"))
|
||||
.and_then(|s| s.get("type"))
|
||||
.and_then(Value::as_str);
|
||||
assert!(
|
||||
text_type == Some("string"),
|
||||
"{path_name} {status} is a plain-text extractor slot (PRJ-19)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let call_responses_map = responses(&spec, PATH_CALL, "post");
|
||||
let call_422 = call_responses_map.get("422").unwrap();
|
||||
assert!(
|
||||
call_422
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap()
|
||||
.contains("plain-text body"),
|
||||
"the /call 422 description distinguishes the extractor variant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_drift_check_after_prj19_additions() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_SEARCH, "get");
|
||||
assert!(
|
||||
!responses.contains_key("401") && !responses.contains_key("403"),
|
||||
"PRJ-15 unaffected by PRJ-19 additions: /search still carries no 401/403"
|
||||
);
|
||||
assert!(responses.contains_key("404"));
|
||||
}
|
||||
|
||||
// --- PRJ-04: operation-error projection honesty ------------------------
|
||||
@@ -1219,6 +1453,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_prefixed_protocol_status_merges_into_shared_response() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
register(
|
||||
&mut registry,
|
||||
external_spec("svc/op", vec![error("HTTP_404", Some(404))]),
|
||||
);
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses_map = responses(&spec, PATH_CALL, "post");
|
||||
let r404 = responses_map.get("404").unwrap();
|
||||
let schema = response_schema(r404);
|
||||
assert_eq!(
|
||||
schema.get("$ref").and_then(Value::as_str),
|
||||
Some("#/components/responses/NotFound"),
|
||||
"PRJ-17: the shared NOT_FOUND response survives the merge"
|
||||
);
|
||||
let variant_refs: Vec<String> = schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.expect("PRJ-17: op-declared variants appended to the shared response's oneOf")
|
||||
.iter()
|
||||
.filter_map(|v| v.get("$ref").and_then(Value::as_str).map(str::to_string))
|
||||
.collect();
|
||||
assert!(
|
||||
variant_refs.contains(&"#/components/schemas/CallError_HTTP_404".to_string()),
|
||||
"the HTTP_404 variant is listed alongside the shared response: {variant_refs:?}"
|
||||
);
|
||||
assert!(
|
||||
spec.raw["components"]["schemas"]["CallError_HTTP_404"]
|
||||
.pointer("/properties/code/enum/0")
|
||||
.is_some(),
|
||||
"the merged variant component is defined"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_prefixed_unauthorized_status_appends_to_401_one_of() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
register(
|
||||
&mut registry,
|
||||
external_spec("svc/op", vec![error("HTTP_401", Some(401))]),
|
||||
);
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses_map = responses(&spec, PATH_CALL, "post");
|
||||
let refs = one_of_refs_of(response_schema(responses_map.get("401").unwrap()));
|
||||
assert!(refs.contains(&"#/components/schemas/CallErrorForbidden".to_string()));
|
||||
assert!(refs.contains(&"#/components/schemas/CallError_HTTP_401".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_error_without_http_status_not_projected() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
@@ -1289,17 +1572,19 @@ mod tests {
|
||||
// --- PRJ-05: /subscribe 200 + in-band error contract -------------------
|
||||
|
||||
#[test]
|
||||
fn subscribe_documents_only_200_and_extractor_400() {
|
||||
fn subscribe_documents_200_and_extractor_slots() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_SUBSCRIBE, "post");
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
2,
|
||||
"200 + extractor 400 only: {responses:?}"
|
||||
4,
|
||||
"200 + extractor 400/415/422 only: {responses:?}"
|
||||
);
|
||||
assert!(responses.contains_key("200"));
|
||||
assert!(responses.contains_key("400"));
|
||||
assert!(responses.contains_key("415"));
|
||||
assert!(responses.contains_key("422"));
|
||||
let content = &responses["200"]["content"];
|
||||
assert!(content.get("text/event-stream").is_some());
|
||||
assert!(content.get("application/json").is_none());
|
||||
@@ -1461,38 +1746,35 @@ mod tests {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_PUBLISH, "post");
|
||||
let refs_400: Vec<String> = {
|
||||
let schema = response_schema(&responses["400"]);
|
||||
schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.get("$ref").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let refs_400 = one_of_refs_of(response_schema(&responses["400"]));
|
||||
assert!(refs_400.contains(&"#/components/schemas/CallErrorInvalidInput".to_string()));
|
||||
let refs_401: Vec<String> = {
|
||||
let schema = response_schema(&responses["401"]);
|
||||
schema
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.get("$ref").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let refs_401 = one_of_refs_of(response_schema(&responses["401"]));
|
||||
assert!(
|
||||
refs_401.contains(&"#/components/schemas/CallErrorInvalidOperationType".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_400_documents_only_the_framing_contract() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_PUBLISH, "post");
|
||||
let refs_400 = one_of_refs_of(response_schema(&responses["400"]));
|
||||
assert_eq!(
|
||||
refs_400,
|
||||
vec!["#/components/schemas/CallErrorInvalidInput".to_string()],
|
||||
"PRJ-18: /publish 400 oneOf is exactly the INVALID_INPUT framing code"
|
||||
);
|
||||
let description = responses["400"]
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!description.contains("reported INVALID_OPERATION_TYPE"),
|
||||
"PRJ-18: the 400 description must not claim the 401-reported condition as a 400 outcome: {description}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- /call protocol statuses --------------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -1500,7 +1782,9 @@ mod tests {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_CALL, "post");
|
||||
for status in ["200", "400", "401", "403", "404", "422", "500", "504"] {
|
||||
for status in [
|
||||
"200", "400", "401", "403", "404", "415", "422", "500", "504",
|
||||
] {
|
||||
assert!(responses.contains_key(status), "/call {status} documented");
|
||||
}
|
||||
assert!(
|
||||
@@ -1513,6 +1797,95 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_401_covers_the_identity_split_codes() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_CALL, "post");
|
||||
let refs_401 = one_of_refs_of(response_schema(&responses["401"]));
|
||||
assert!(
|
||||
refs_401.contains(&"#/components/schemas/CallErrorForbidden".to_string()),
|
||||
"PRJ-20: FORBIDDEN lands at 401 without identity: {refs_401:?}"
|
||||
);
|
||||
assert!(
|
||||
refs_401.contains(&"#/components/schemas/CallErrorInvalidOperationType".to_string()),
|
||||
"PRJ-20: unauthenticated Sub/Pub call reports INVALID_OPERATION_TYPE at 401 (error.rs): {refs_401:?}"
|
||||
);
|
||||
let description = responses["401"]
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
assert!(
|
||||
description.contains("INVALID_OPERATION_TYPE"),
|
||||
"the 401 description names both codes: {description}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_documents_no_unreachable_500() {
|
||||
let registry = OperationRegistry::new();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let responses = responses(&spec, PATH_BATCH, "post");
|
||||
assert!(
|
||||
!responses.contains_key("500"),
|
||||
"PRJ-21: batch dispatch failures are in-band entries; no HTTP 500 exists to document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_result_entry_error_refs_a_defined_component() {
|
||||
let registry = sample_registry();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let entry = &spec.raw["components"]["schemas"]["BatchResultEntry"];
|
||||
let error_ref = entry
|
||||
.pointer("/properties/error/$ref")
|
||||
.and_then(Value::as_str)
|
||||
.expect("BatchResultEntry.error is a $ref");
|
||||
let target = error_ref.trim_start_matches("#/components/schemas/");
|
||||
assert!(
|
||||
spec.raw["components"]["schemas"].get(target).is_some(),
|
||||
"PRJ-16b: {error_ref} resolves inside components.schemas"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_error_component_covers_protocol_and_operation_codes() {
|
||||
let registry = sample_registry();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let batch_error = &spec.raw["components"]["schemas"]["BatchError"];
|
||||
let arms: Vec<&str> = batch_error
|
||||
.get("oneOf")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.get("$ref").and_then(Value::as_str))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for expected in [
|
||||
"#/components/schemas/CallErrorNotFound",
|
||||
"#/components/schemas/CallErrorInternal",
|
||||
"#/components/schemas/BatchOperationError",
|
||||
] {
|
||||
assert!(arms.contains(&expected), "PRJ-16b: {expected} in {arms:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_extension_key_inside_components_schemas() {
|
||||
let registry = sample_registry();
|
||||
let spec = to_openapi(®istry).unwrap();
|
||||
let schemas = spec.raw["components"]["schemas"]
|
||||
.as_object()
|
||||
.expect("schemas object");
|
||||
for name in schemas.keys() {
|
||||
assert!(
|
||||
!name.starts_with("x-"),
|
||||
"PRJ-23: {name} is not a schema name — extension keys are illegal inside components.schemas"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_error_component_schemas_pin_codes() {
|
||||
let registry = OperationRegistry::new();
|
||||
|
||||
Reference in New Issue
Block a user