Merge branch 'wt/review-002-projection-truthfulness'

This commit is contained in:
2026-08-30 23:08:42 +00:00
5 changed files with 493 additions and 115 deletions
+2 -8
View File
@@ -209,7 +209,6 @@ impl ToMcpGateway {
async fn handle_batch(
&self,
search_filter: Option<String>,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
@@ -248,7 +247,6 @@ impl ToMcpGateway {
.await;
results.push(envelope_to_value(response));
}
let _ = search_filter;
CallToolResult::structured(serde_json::json!({ "results": results }))
}
}
@@ -464,7 +462,7 @@ impl rmcp::handler::server::ServerHandler for ToMcpGateway {
TOOL_SEARCH => this.handle_search(search_filter, identity).await,
TOOL_SCHEMA => this.handle_schema(arguments, identity).await,
TOOL_CALL => this.handle_call(arguments, identity).await,
TOOL_BATCH => this.handle_batch(search_filter, arguments, identity).await,
TOOL_BATCH => this.handle_batch(arguments, identity).await,
unknown => {
let err = CallError::new(
"NOT_FOUND",
@@ -673,11 +671,7 @@ mod tests {
TOOL_SEARCH => gateway.handle_search(search_filter, identity).await,
TOOL_SCHEMA => gateway.handle_schema(arguments, identity).await,
TOOL_CALL => gateway.handle_call(arguments, identity).await,
TOOL_BATCH => {
gateway
.handle_batch(search_filter, arguments, identity)
.await
}
TOOL_BATCH => gateway.handle_batch(arguments, identity).await,
unknown => {
let err = CallError::new(
"NOT_FOUND",
+470 -97
View File
@@ -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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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(&registry).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();
+3 -2
View File
@@ -889,9 +889,10 @@ mod tests {
let text = String::from_utf8_lossy(&response);
assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}");
assert!(text.contains("application/json"), "got: {text}");
// The 6-endpoint gateway doc with the version from the /publish addition.
// The 6-endpoint gateway doc; the version tracks the projection
// truthfulness pass.
assert!(text.contains("\"/publish\""), "publish path in doc");
assert!(text.contains("1.2.0"), "info.version 1.2.0 in doc");
assert!(text.contains("1.3.0"), "info.version 1.3.0 in doc");
assert!(text.contains("gatewayPublish"), "publish operationId");
let _ = server_task.await;