//! `to_openapi`: gateway projection of the local operation registry into a //! fixed 6-endpoint OpenAPI 3.0 document (ADR-042, as extended by //! ADR-068). //! //! `to_openapi` is a pure projection (ADR-017 §5): it consumes the //! registry and produces a spec; it does not modify the registry, //! register operations, or implement `OperationAdapter`. The generated //! doc describes the 6 fixed gateway endpoints (`/search`, `/schema`, //! `/call`, `/batch`, `/subscribe`, `/publish`) — the sole HTTP invoke //! path (ADR-047). The per-caller operation surface is discovered at //! runtime through AccessControl-filtered `/search`, not preloaded into //! the doc (ADR-042 §3). //! //! `info.version` is a semver constant tracking the **gateway endpoint //! contract**, not the operation set — per-caller operation changes do //! not bump the version (ADR-045). `1.0.0` was the 5-endpoint contract; //! `1.1.0` adds `/publish` (ADR-068) — minor (additive). `1.2.0` aligns //! 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. `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 //! //! The doc mirrors `gateway::error`'s actual mapping (review-001 //! PRJ-03/PRJ-04 decisions): //! //! - `INVALID_INPUT` and identity-resolved `INVALID_OPERATION_TYPE` are //! documented at `422` — the status the runtime emits. Axum extractor //! 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, 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. Non-protocol statuses carry //! `x-runtime-behavior: 500` (the PRJ-04 project-honest decision); //! protocol-status declarations from `HTTP_`-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. use std::collections::BTreeMap; use serde_json::{json, Value}; use alkcall::client::AdapterError; use alkcall::registry::registration::OperationRegistry; use alkcall::registry::spec::ErrorDefinition; use super::openapi_spec::OpenAPISpec; use crate::gateway::MAX_BATCH_OPERATIONS; const GATEWAY_VERSION: &str = "1.3.0"; const GATEWAY_TITLE: &str = "alk gateway"; const OPENAPI_VERSION: &str = "3.0.0"; const PATH_SEARCH: &str = "/search"; const PATH_SCHEMA: &str = "/schema"; const PATH_CALL: &str = "/call"; const PATH_BATCH: &str = "/batch"; const PATH_SUBSCRIBE: &str = "/subscribe"; const PATH_PUBLISH: &str = "/publish"; const STATUS_BAD_REQUEST: u16 = 400; const STATUS_UNAUTHORIZED: u16 = 401; const STATUS_FORBIDDEN: u16 = 403; const STATUS_NOT_FOUND: u16 = 404; const STATUS_UNPROCESSABLE: u16 = 422; const STATUS_INTERNAL: u16 = 500; const STATUS_TIMEOUT: u16 = 504; const HTTP_PREFIX: &str = "HTTP_"; const CODE_INVALID_INPUT: &str = "INVALID_INPUT"; const CODE_INVALID_OPERATION_TYPE: &str = "INVALID_OPERATION_TYPE"; const CODE_FORBIDDEN: &str = "FORBIDDEN"; 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/"; /// Project the registry into the fixed 6-endpoint gateway doc (ADR-042). /// /// Returns [`AdapterError::SchemaParse`] if the generated doc does not /// re-validate against the structural checks in `OpenAPISpec::from_value` /// — a would-be invariant violation of `build_doc`, not a caller-facing /// input error. The HTTP surface serves only a generic `500` for this /// (`/openapi.json` handler caches the serialized doc; SRV-09): no /// serde/parse internals reach the wire. pub fn to_openapi(registry: &OperationRegistry) -> Result { let operation_errors = gather_operation_errors(registry); let raw = build_doc(&operation_errors); OpenAPISpec::from_value(raw) } fn build_doc(operation_errors: &BTreeMap) -> Value { let paths = json!({ PATH_SEARCH: search_path_item(), PATH_SCHEMA: schema_path_item(), PATH_CALL: call_path_item(operation_errors), PATH_BATCH: batch_path_item(), PATH_SUBSCRIBE: subscribe_path_item(), PATH_PUBLISH: publish_path_item(operation_errors), }); json!({ "openapi": OPENAPI_VERSION, "info": { "title": GATEWAY_TITLE, "version": GATEWAY_VERSION, "description": "alk gateway: 6 fixed endpoints gating access to the operation registry. The per-caller operation surface is discovered via /search (AccessControl-filtered), not preloaded into this doc." }, "paths": paths, "components": components(operation_errors), "security": [{ SCHEME_BEARER: [] }] }) } fn search_path_item() -> Value { json!({ "get": { "operationId": "gatewaySearch", "summary": "List the operations this caller may invoke (AccessControl-filtered). Important: the response body carries the envelope wrapper {request_id, result, output}; the operations array is under output.", "security": [{ SCHEME_BEARER: [] }], "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"), "Dispatcher failure."), "504": json_response(ref_response("Timeout"), "The bounded Once-op dispatch exceeded the 30 s gateway deadline (retryable)."), } } }) } fn schema_path_item() -> Value { json!({ "get": { "operationId": "gatewaySchema", "summary": "Get an operation's full OperationSpec (output wrapped in the envelope under output; includes op_type, visibility, access_control, channel_open, publish_schema).", "security": [{ SCHEME_BEARER: [] }], "parameters": [ { "name": "name", "in": "query", "required": true, "schema": { "type": "string" } } ], "responses": { "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"), "Token resolved, but AccessControl denies the operation."), "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). 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"), "The bounded Once-op dispatch exceeded the 30 s gateway deadline (retryable)."), } } }) } fn call_path_item(operation_errors: &BTreeMap) -> Value { json!({ "post": { "operationId": "gatewayCall", "summary": "Invoke an operation by name with a flat JSON input. The final ResponseEnvelope is the response.", "security": [{ SCHEME_BEARER: [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": ref_schema("CallRequest") } } }, "responses": call_responses(operation_errors) } }) } /// 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_`-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) { for (status, projection) in operation_errors { let key = status.to_string(); let code_variants: Vec = 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 /// order is sorted). fn call_responses(operation_errors: &BTreeMap) -> Value { let mut responses = json!({ "200": json_response(ref_schema("CallOk"), "The operation's final ResponseEnvelope: request_id, result=ok, output."), "400": plain_text_extractor_rejection(), "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( { 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(), ]), "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)."), "504": json_response(ref_response("Timeout"), "The Once-op dispatch exceeded the 30 s gateway deadline (retryable)."), }); merge_operation_errors(&mut responses, operation_errors); responses } /// `/publish`'s responses: the shared dispatch-path statuses plus the /// NDJSON-framing 400 and the identity-split 401 (PRJ-03). fn publish_path_item(operation_errors: &BTreeMap) -> Value { json!({ "post": { "operationId": "gatewayPublish", "summary": "Invoke a Pub operation. The body is NDJSON streamed (never fully buffered): the first line = {\"operation\": \"/service/op\", \"chunk\": {...}}, subsequent lines are chunk values. The final ResponseEnvelope is the response. A terminal error is a plain HTTP status + JSON body (not an NDJSON line).", "security": [{ SCHEME_BEARER: [] }], "requestBody": { "required": true, "content": { "application/x-ndjson": { "schema": ref_schema("NdjsonBody") } } }, "responses": publish_responses(operation_errors) } }) } fn publish_responses(operation_errors: &BTreeMap) -> Value { let mut responses = json!({ "200": json_response(ref_schema("CallOk"), "The operation's final ResponseEnvelope: request_id, result=ok, output."), "400": json_response(one_of_refs(&[ "CallErrorInvalidInput".to_string(), ]), "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(), ]), "No bearer token or it did not resolve: AccessControl denial (FORBIDDEN) or the non-Pub-op dispatch-path report (INVALID_OPERATION_TYPE) without identity."), "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: a chunk failed the operation's publish_schema validation (INVALID_INPUT with details.chunk), a later NDJSON line was not valid JSON, or the operation's type is not Pub (INVALID_OPERATION_TYPE, token present)."), "500": json_response(one_of_refs(&[ "CallErrorInternal".to_string(), "PublishFailure".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)."), "504": json_response(ref_response("Timeout"), "The Once-op final envelope dispatch exceeded the 30 s gateway deadline (retryable)."), }); merge_operation_errors(&mut responses, operation_errors); responses } fn batch_path_item() -> Value { json!({ "post": { "operationId": "gatewayBatch", "summary": "Invoke multiple operations in one request. The response body is {request_id, result, output: {results: [...] }}. Per-call failures are in-band result:error items; only the request-level cap failure is an HTTP error status.", "security": [{ SCHEME_BEARER: [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "array", "items": ref_schema("CallRequest"), "maxItems": MAX_BATCH_OPERATIONS, } } } }, "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. 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); 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."), } } }) } fn subscribe_path_item() -> Value { json!({ "post": { "operationId": "gatewaySubscribe", "summary": "Invoke a streaming (Sub) operation. The response is always HTTP 200 text/event-stream; failures arrive as in-band event:error SSE frames (GW-12), not HTTP error statuses. The first event:error frame is terminal: it is followed by stream close (call.completed-to-come).", "security": [{ SCHEME_BEARER: [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": ref_schema("CallRequest") } } }, "responses": { "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(), } } }) } /// The `/subscribe` 200 response: `text/event-stream` content with the /// full in-band error contract in the description (PRJ-05 / GW-12). fn sse_response(description: &str) -> Value { json!({ "description": description, "content": { "text/event-stream": { "schema": ref_schema("SseStream") } } }) } fn json_response(schema: Value, description: &str) -> Value { json!({ "description": description, "content": { "application/json": { "schema": schema } } }) } fn components(operation_errors: &BTreeMap) -> Value { let mut schemas = json!({ "CallRequest": call_request_schema(), "CallOk": call_ok_schema(), "SearchResponse": search_response_schema(), "SearchOperation": search_operation_schema(), "SchemaResponse": envelope_ref_schema("OperationSpecOutput"), "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(), "CallFailure": { "description": "In-band operation-declared failure envelope.", "allOf": [failure_envelope_schema(operation_errors)] }, "PublishFailure": { "description": "In-band operation-declared failure envelope.", "allOf": [failure_envelope_schema(operation_errors)] }, "CallErrorInvalidInput": call_error_schema(CODE_INVALID_INPUT), "CallErrorInvalidOperationType": call_error_schema(CODE_INVALID_OPERATION_TYPE), "CallErrorForbidden": call_error_schema(CODE_FORBIDDEN), "CallErrorNotFound": call_error_schema(CODE_NOT_FOUND), "CallErrorInternal": call_error_schema(CODE_INTERNAL), "CallErrorTimeout": call_error_schema(CODE_TIMEOUT) }); 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": { SCHEME_BEARER: { "type": "http", "scheme": "bearer", "description": "Bearer token resolved via IdentityProvider::resolve_from_token (ADR-004). Endpoints marked optional security are callable without a token; AccessControl then decides." } }, "responses": { "Unauthorized": json_response(ref_schema("CallErrorForbidden"), "No bearer token or it did not resolve."), "Forbidden": json_response(ref_schema("CallErrorForbidden"), "The token resolved, but AccessControl denies the operation."), "NotFound": json_response(ref_schema("CallErrorNotFound"), "The operation is unknown or Internal."), "InvalidInput": one_of_response(), "Internal": json_response(ref_schema("CallErrorInternal"), "Dispatcher failure."), "Timeout": json_response(ref_schema("CallErrorTimeout"), "The bounded Once-op dispatch exceeded the 30 s gateway deadline (retryable)."), }, "schemas": schemas }) } /// The operation-declared codes, deduplicated and sorted across every /// status (BTreeMap-fold, PRJ-12 determinism): the code set the merged /// `CallError_` components and `BatchOperationError`'s enum cover. fn operation_error_codes(operation_errors: &BTreeMap) -> Vec { 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(&[ "CallErrorInvalidInput".to_string(), "CallErrorInvalidOperationType".to_string(), ]), "Dispatch-path client fault.", ) } fn call_request_schema() -> Value { json!({ "type": "object", "required": ["operation"], "properties": { "operation": { "type": "string", "description": "The fully-qualified operation name to invoke. A leading slash is accepted and stripped." }, "input": { "type": "object", "description": "The JSON input object passed to the operation.", "default": {} } } }) } fn call_ok_schema() -> Value { json!({ "type": "object", "required": ["request_id", "result", "output"], "properties": { "request_id": { "type": "string" }, "result": { "type": "string", "enum": ["ok"] }, "output": { "description": "The operation's output value." } } }) } fn call_error_shape_schema() -> Value { json!({ "type": "object", "required": ["code", "message", "retryable"], "properties": { "code": { "type": "string" }, "message": { "type": "string" }, "retryable": { "type": "boolean" }, "details": { "type": "object", "description": "Optional structured details (e.g. retry_after for Retry-After statuses)." } } }) } fn call_error_schema(code: &str) -> Value { let mut schema = call_error_shape_schema(); schema["properties"]["code"] = json!({ "type": "string", "enum": [code] }); 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) -> Value { let _ = operation_errors; json!({ "type": "object", "required": ["code", "message", "retryable"], "properties": { "code": { "type": "string" }, "message": { "type": "string" }, "retryable": { "type": "boolean" }, "details": { "type": "object", "description": "Optional structured details (e.g. retry_after for Retry-After statuses)." } } }) } fn search_operation_schema() -> Value { json!({ "type": "object", "required": ["name", "namespace", "op_type"], "properties": { "name": { "type": "string", "description": "Fully-qualified operation name (e.g. fs/readFile)." }, "namespace": { "type": "string", "description": "The segment before the first / (empty when the name has none)." }, "op_type": { "type": "string", "enum": ["query", "mutation", "sub", "pub"] } } }) } fn search_response_schema() -> Value { envelope_ref_schema("operations_array_schema()") } fn operations_array_schema() -> Value { json!({ "type": "object", "required": ["operations"], "properties": { "operations": { "type": "array", "items": ref_schema("SearchOperation") } } }) } /// An envelope (`{request_id, result, output}`) wrapping the given /// output schema value. fn envelope_ref_schema(output: &str) -> Value { let output_schema = if output == "operations_array_schema()" { operations_array_schema() } else { ref_schema(output) }; json!({ "type": "object", "required": ["request_id", "result", "output"], "properties": { "request_id": { "type": "string" }, "result": { "type": "string", "enum": ["ok"] }, "output": output_schema } }) } fn operation_spec_output_schema() -> Value { json!({ "type": "object", "required": ["name", "namespace", "op_type", "visibility", "input_schema", "output_schema", "error_schemas", "access_control"], "properties": { "name": { "type": "string" }, "namespace": { "type": "string" }, "op_type": { "type": "string", "enum": ["query", "mutation", "sub", "pub"] }, "visibility": { "type": "string", "enum": ["external", "internal"] }, "input_schema": { "description": "JSON Schema for the operation's input." }, "output_schema": { "description": "JSON Schema for the operation's output." }, "error_schemas": { "type": "array", "items": { "type": "object" } }, "access_control": { "type": "object", "description": "The operation's AccessControl requirements (required_scopes, required_scopes_any, resource_type, resource_action)." }, "channel_open": { "type": "boolean", "description": "Marker (ADR-047): when true, the op's stream is binary and the channels layer allocates a data channel for it. Absent for JSON-stream ops." }, "publish_schema": { "description": "Schema for each published chunk's input (Pub ops only, ADR-046 §4). Absent for Query/Mutation/Sub ops and Pub ops with no per-chunk validation." } } }) } fn batch_result_entry_schema() -> Value { json!({ "type": "object", "required": ["request_id", "result"], "properties": { "request_id": { "type": "string" }, "result": { "type": "string", "enum": ["ok", "error"] }, "output": { "description": "The operation's output when result=ok." }, "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", "required": ["results"], "properties": { "results": { "type": "array", "items": ref_schema("BatchResultEntry") } } }) } fn batch_cap_exceeded_schema() -> Value { json!({ "type": "object", "required": ["code", "message"], "properties": { "code": { "type": "string", "enum": ["INVALID_INPUT"] }, "message": { "type": "string" } } }) } fn sse_stream_schema() -> Value { json!({ "type": "string", "description": "text/event-stream body. Framing: keep-alive comment frames every 15 s; Success events carry `data:` (the JSON-serialized output value) and `retry: 15000`; an Error event carries `event: error`, data: the serialized CallError and `retry: 15000`, and is terminal (followed by stream close)." }) } fn ndjson_body_schema() -> Value { json!({ "type": "string", "description": "NDJSON: the first line is {\"operation\": \"/service/op\", \"chunk\": {...}}; subsequent lines are chunk values (one published chunk per line; 2 MiB per-line cap)." }) } /// An inline JSON schema reference into `components.schemas`. fn ref_schema(name: &str) -> Value { json!({ "$ref": format!("{RESPONSE_REF}{name}") }) } /// A `oneOf` over inline JSON schema references. fn one_of_refs(names: &[String]) -> Value { let variants: Vec = names .iter() .map(|n| json!({ "$ref": format!("{RESPONSE_REF}{n}") })) .collect(); json!({ "oneOf": variants }) } /// A response object reference into the shared `components.responses` /// error shapes this projection defines. fn ref_response(name: &str) -> Value { json!({ "$ref": format!("#/components/responses/{name}") }) } /// A `400` response whose body is the web framework's plain-text /// rejection — NOT the `CallError` JSON envelope. The route extractors /// (`Json`, `Json>`, `Query`) /// reject malformed request framing before dispatch; their bodies are /// 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 (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" } } } }) } /// The operation-level error projections, keyed by the registry-declared /// `http_status`. Deterministic: registry iteration order is folded into /// BTreeMaps before anything is emitted (PRJ-12). fn gather_operation_errors(registry: &OperationRegistry) -> BTreeMap { let mut by_status: BTreeMap> = BTreeMap::new(); for spec in registry.list_operations() { for error in &spec.error_schemas { let Some(status) = error.http_status else { continue; }; if is_protocol_status(status) && !is_http_prefixed_code(&error.code) { continue; } by_status .entry(status) .or_default() .entry(error.code.clone()) .or_insert_with(|| error.clone()); } } let mut out: BTreeMap = BTreeMap::new(); for (status, codes) in by_status { let runtime_diverges = codes.keys().any(|code| !is_http_prefixed_code(code)); out.insert( status, json!({ "description": "The registry declares operation-level error codes at this status. Runtime note: the gateway error mapper is purely code-driven; a code without an HTTP_ prefix surfaces as 500 instead of this status (x-runtime-behavior: 500).", "x-runtime-behavior": if runtime_diverges { json!(500) } else { Value::Null }, "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "string", "enum": codes.keys().cloned().collect::>(), }, "message": { "type": "string" }, "retryable": { "type": "boolean" }, "details": { "type": "object", "description": "Optional structured details (e.g. retry_after for Retry-After statuses)." } }, "required": ["code", "message", "retryable"] } } } }), ); } out } fn is_protocol_status(status: u16) -> bool { matches!( status, STATUS_BAD_REQUEST | STATUS_UNAUTHORIZED | STATUS_FORBIDDEN | STATUS_NOT_FOUND | STATUS_UNPROCESSABLE | STATUS_INTERNAL | STATUS_TIMEOUT ) } fn is_http_prefixed_code(code: &str) -> bool { code.starts_with(HTTP_PREFIX) && code[HTTP_PREFIX.len()..].parse::().is_ok() } #[cfg(test)] mod tests { use super::*; use alkcall::core::types::Capabilities; use alkcall::protocol::wire::ResponseEnvelope; use alkcall::registry::registration::{ make_handler, HandlerKind, HandlerRegistration, OperationProvenance, }; use alkcall::registry::spec::{ AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility, }; use serde_json::{json, Map}; fn noop_handler() -> alkcall::registry::registration::Handler { make_handler(|_input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, Value::Null) }) } fn register(registry: &mut OperationRegistry, spec: OperationSpec) { registry .register(HandlerRegistration::new( spec, HandlerKind::Once(noop_handler()), OperationProvenance::Local, None, None, Capabilities::new(), )) .unwrap(); } fn external_spec(name: &str, errors: Vec) -> OperationSpec { OperationSpec::new( name, OperationType::Query, Visibility::External, json!({}), json!({}), errors, AccessControl::default(), None, ) } fn error(code: &str, http_status: Option) -> ErrorDefinition { ErrorDefinition { code: code.to_string(), description: format!("error {code}"), schema: json!({ "type": "object" }), http_status, } } fn paths_object(spec: &OpenAPISpec) -> &Map { spec.raw .get("paths") .and_then(Value::as_object) .unwrap_or_else(|| panic!("paths object present")) } fn path(spec: &OpenAPISpec, name: &str) -> Map { paths_object(spec) .get(name) .and_then(Value::as_object) .unwrap_or_else(|| panic!("path {name} present")) .clone() } fn responses(spec: &OpenAPISpec, name: &str, method: &str) -> Map { path(spec, name) .get(method) .and_then(Value::as_object) .unwrap_or_else(|| panic!("operation {method} {name} present")) .get("responses") .and_then(Value::as_object) .unwrap_or_else(|| panic!("responses present")) .clone() } fn response_schema(response: &Value) -> &Value { response .get("content") .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap_or_else(|| panic!("application/json schema present")) } fn code_enum(spec: &OpenAPISpec, schema: &Value) -> Vec { let schema = match schema.get("$ref").and_then(Value::as_str) { Some(reference) => spec.raw.pointer(&reference[1..]).expect("ref target"), None => schema, }; schema .pointer("/properties/code/enum") .and_then(Value::as_array) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(str::to_string)) .collect() }) .unwrap_or_default() } fn one_of_refs_of(schema: &Value) -> Vec { 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() } // --- structural basics ------------------------------------------------ #[test] fn empty_registry_produces_six_gateway_paths() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let paths = paths_object(&spec); assert_eq!(paths.len(), 6); for name in [ PATH_SEARCH, PATH_SCHEMA, PATH_CALL, PATH_BATCH, PATH_SUBSCRIBE, PATH_PUBLISH, ] { assert!(paths.contains_key(name), "{name} present"); } } #[test] fn registry_with_operations_does_not_add_per_operation_paths() { let mut registry = OperationRegistry::new(); register(&mut registry, external_spec("fs/readFile", vec![])); register(&mut registry, external_spec("agent/chat", vec![])); let spec = to_openapi(®istry).unwrap(); let paths = paths_object(&spec); assert_eq!(paths.len(), 6); assert!(!paths.contains_key("/fs/readFile")); assert!(!paths.contains_key("/agent/chat")); } #[test] fn info_version_is_1_3_0_after_projection_truthfulness() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let version = spec .raw .pointer("/info/version") .and_then(Value::as_str) .unwrap(); assert_eq!(version, GATEWAY_VERSION); assert_eq!( version, "1.3.0", "minor bump: doc-contract corrections only — the wire contract is unchanged (ADR-045)" ); } #[test] fn openapi_field_is_3_0_0_and_title_present() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); assert_eq!( spec.raw.get("openapi").and_then(Value::as_str), Some(OPENAPI_VERSION) ); assert_eq!( spec.raw.pointer("/info/title").and_then(Value::as_str), Some(GATEWAY_TITLE) ); } #[test] fn doc_validates_against_openapiv3_parsing() { let registry = OperationRegistry::new(); 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("gateway doc parses as OpenAPI 3.0"); assert_eq!(parsed.openapi, OPENAPI_VERSION); 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::>() ); assert_eq!(spec.paths.len(), 6); } // --- PRJ-15: securitySchemes / security ------------------------------- #[test] fn components_declare_the_bearer_security_scheme() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let scheme = spec .raw .pointer("/components/securitySchemes/bearerAuth") .expect("bearerAuth scheme declared (ADR-004)"); assert_eq!(scheme.get("type"), Some(&json!("http"))); assert_eq!(scheme.get("scheme"), Some(&json!("bearer"))); assert_eq!( spec.raw .get("security") .and_then(Value::as_array) .map(|a| a.len()), Some(1) ); } // --- PRJ-01: /search envelope + item fields ---------------------------- #[test] fn search_200_documents_the_envelope_and_real_item_fields() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let ok = responses(&spec, PATH_SEARCH, "get") .get("200") .unwrap() .clone(); assert_eq!( ok.get("description").and_then(Value::as_str), Some("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.") ); let schema = response_schema(&ok); assert_eq!( schema.get("$ref").and_then(Value::as_str), Some("#/components/schemas/SearchResponse") ); let resolved = spec .raw .pointer("/components/schemas/SearchResponse") .unwrap(); assert_eq!( resolved.pointer("/properties/result/enum"), Some(&json!(["ok"])) ); assert!(resolved.pointer("/properties/request_id").is_some()); let operations = resolved.pointer("/properties/output/properties/operations"); assert!(operations.is_some(), "operations array under output"); let item = resolved .pointer("/properties/output/properties/operations/items/$ref") .and_then(Value::as_str) .unwrap(); assert_eq!(item, "#/components/schemas/SearchOperation"); let item_schema = &spec.raw["components"]["schemas"]["SearchOperation"]; assert!(item_schema.pointer("/properties/name").is_some()); assert!(item_schema.pointer("/properties/namespace").is_some()); assert!(item_schema.pointer("/properties/op_type/enum").is_some()); assert!( item_schema.pointer("/properties/description").is_none(), "services/list items carry no description field (PRJ-01)" ); let summary = spec.raw["paths"][PATH_SEARCH]["get"]["summary"] .as_str() .unwrap(); assert!( !summary.contains("description"), "PRJ-01: summary must not claim descriptions" ); assert_eq!( ok.pointer("/description").and_then(Value::as_str), Some("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.") ); } #[test] fn search_does_not_document_401_403_but_documents_404() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_SEARCH, "get"); assert!( !responses.contains_key("401"), "PRJ-15: /search 401 cannot occur" ); assert!( !responses.contains_key("403"), "PRJ-15: /search 403 cannot occur" ); assert!( responses.contains_key("404"), "PRJ-15: /search 404 can occur" ); } // --- PRJ-02: /schema envelope + full spec ------------------------------ #[test] fn schema_200_documents_the_envelope_and_full_spec() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let ok = responses(&spec, PATH_SCHEMA, "get") .get("200") .unwrap() .clone(); let schema = response_schema(&ok); assert_eq!( schema.get("$ref").and_then(Value::as_str), Some("#/components/schemas/SchemaResponse") ); let resolved = spec .raw .pointer("/components/schemas/SchemaResponse") .unwrap(); assert_eq!( resolved.pointer("/properties/result/enum"), Some(&json!(["ok"])) ); let output_ref = resolved .pointer("/properties/output/$ref") .and_then(Value::as_str) .unwrap(); assert_eq!(output_ref, "#/components/schemas/OperationSpecOutput"); let output = &spec.raw["components"]["schemas"]["OperationSpecOutput"]; for field in [ "name", "namespace", "op_type", "visibility", "input_schema", "output_schema", "error_schemas", "access_control", "channel_open", "publish_schema", ] { assert!( output.pointer(&format!("/properties/{field}")).is_some(), "OperationSpecOutput carries {field} (PRJ-02)" ); } } #[test] fn schema_documents_404_and_401_403() { 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", "415", "422", "500", "504", ] { assert!( responses.contains_key(status), "/schema {status} documented" ); } } // --- PRJ-03: 422 vs 400, extractor-rejection gap ----------------------- #[test] fn call_documents_422_not_400_invalid_input() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_CALL, "post"); assert!( responses.contains_key("400"), "extractor rejection documented" ); let content = &responses["400"]["content"]; assert!(content.get("text/plain").is_some()); assert!(content.get("application/json").is_none()); let r422 = responses.get("422").unwrap(); let schema = response_schema(r422); let refs = one_of_refs_of(schema); assert!(refs.contains(&"#/components/schemas/CallErrorInvalidInput".to_string())); assert!(refs.contains(&"#/components/schemas/CallErrorInvalidOperationType".to_string())); assert_eq!( spec.raw["components"]["schemas"]["CallErrorInvalidInput"] .pointer("/properties/code/enum/0"), Some(&json!("INVALID_INPUT")), "422 schema carries an INVALID_INPUT code enum" ); } #[test] fn call_400_documents_the_plain_text_extractor_gap() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses_map = responses(&spec, PATH_CALL, "post"); 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!( description.contains("PLAIN-TEXT body"), "the extractor-rejection gap is documented on the 400 itself: {description}" ); assert!(r400["content"].get("application/json").is_none()); } #[test] fn batch_documents_400_json_shape_and_422_free_call_entries() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_BATCH, "post"); assert!(responses.contains_key("400")); let schema = response_schema(responses.get("400").unwrap()); 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 ------------------------ #[test] fn operation_errors_projected_onto_call_with_runtime_behavior_annotated() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("fs/readFile", vec![error("RATE_LIMITED", Some(429))]), ); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_CALL, "post"); let r429 = responses.get("429").unwrap(); let schema = response_schema(r429); let codes = code_enum(&spec, schema); assert!(codes.contains(&"RATE_LIMITED".to_string()), "{codes:?}"); assert_eq!( r429.get("x-runtime-behavior"), Some(&json!(500)), "PRJ-04: non-HTTP_* code documented at its declared status but annotated 500-at-runtime" ); assert!( spec.raw["paths"][PATH_CALL]["post"]["responses"]["500"] .to_string() .contains("operation-level error code without HTTP_ prefix"), "the 500 response description explains the runtime landing zone" ); } #[test] fn http_prefixed_error_code_projects_to_status_without_annotation() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("svc/op", vec![error("HTTP_429", Some(429))]), ); let spec = to_openapi(®istry).unwrap(); let responses_map = responses(&spec, PATH_CALL, "post"); let r429 = responses_map.get("429").unwrap(); let codes = code_enum(&spec, response_schema(r429)); assert!(codes.contains(&"HTTP_429".to_string())); assert_eq!( r429.get("x-runtime-behavior"), Some(&Value::Null), "HTTP_-prefixed codes surface at their declared status at runtime: no divergence" ); } #[test] fn protocol_status_declared_by_non_http_prefixed_code_is_dropped() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("svc/op", vec![error("RATE_LIMITED", Some(404))]), ); let spec = to_openapi(®istry).unwrap(); let responses_map = responses(&spec, PATH_CALL, "post"); let r404 = responses_map.get("404").unwrap(); assert_eq!( response_schema(r404).get("$ref").and_then(Value::as_str), Some("#/components/responses/NotFound"), "no operation-error merge: the shared protocol 404 response stands" ); } #[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 = 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(); register( &mut registry, external_spec("svc/op", vec![error("SOME_ERROR", None)]), ); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_CALL, "post"); assert!( responses.len() < 10, "no status-less error projected: {responses:?}" ); } #[test] fn operation_errors_from_multiple_ops_dedupe_and_merge_deterministically() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("svc/a", vec![error("RATE_LIMITED", Some(429))]), ); register( &mut registry, external_spec("svc/b", vec![error("TOO_MANY_REQUESTS", Some(429))]), ); let spec = to_openapi(®istry).unwrap(); let responses_map = responses(&spec, PATH_CALL, "post"); let r429 = responses_map.get("429").unwrap(); let codes = code_enum(&spec, response_schema(r429)); assert_eq!( codes, vec!["RATE_LIMITED", "TOO_MANY_REQUESTS"], "sorted, deduped" ); } #[test] fn internal_operations_excluded_from_error_projection() { let mut registry = OperationRegistry::new(); registry .register(HandlerRegistration::new( OperationSpec::new( "internal/op", OperationType::Query, Visibility::Internal, json!({}), json!({}), vec![error("INTERNAL_ERROR", Some(418))], AccessControl::default(), None, ), HandlerKind::Once(noop_handler()), OperationProvenance::Local, None, None, Capabilities::new(), )) .unwrap(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_CALL, "post"); assert!( !responses.contains_key("418"), "internal op errors not projected" ); } // --- PRJ-05: /subscribe 200 + in-band error contract ------------------- #[test] 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(), 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()); } #[test] fn subscribe_200_documents_the_event_error_contract() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses_map = responses(&spec, PATH_SUBSCRIBE, "post"); let ok = responses_map.get("200").unwrap(); let description = ok.get("description").and_then(Value::as_str).unwrap(); for needle in [ "event:error", "terminal", "unknown op", "Internal op", "ACL denial", "15 s", ] { assert!( description.contains(needle), "200 description documents {needle}: {description}" ); } } // --- PRJ-14: $refs, no duplicate inlining ------------------------------ #[test] fn request_bodies_use_refs_not_inlined_schemas() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); for path_name in [PATH_CALL, PATH_SUBSCRIBE] { let schema = &spec.raw["paths"][path_name]["post"]["requestBody"]["content"] ["application/json"]["schema"]; assert_eq!( schema.get("$ref"), Some(&json!("#/components/schemas/CallRequest")), "{path_name} requestBody uses $ref (PRJ-14)" ); } let batch_items = &spec.raw["paths"][PATH_BATCH]["post"]["requestBody"]["content"] ["application/json"]["schema"]["items"]; assert_eq!( batch_items.get("$ref"), Some(&json!("#/components/schemas/CallRequest")) ); } #[test] fn call_request_component_shape_matches_the_wire_struct() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let schema = &spec.raw["components"]["schemas"]["CallRequest"]; assert_eq!(schema.get("type"), Some(&json!("object"))); assert!(schema.pointer("/properties/operation").is_some()); assert!(schema.pointer("/properties/input").is_some()); assert!(schema.pointer("/required/0").is_some()); assert!(schema.as_object().unwrap().contains_key("required")); } // --- golden: byte-identical regeneration (determinism, PRJ-12) -------- fn sample_registry() -> OperationRegistry { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec( "svc/alpha", vec![ error("RATE_LIMITED", Some(429)), error("HTTP_404", Some(429)), ], ), ); register( &mut registry, external_spec("svc/beta", vec![error("TOO_MANY_REQUESTS", Some(429))]), ); register(&mut registry, external_spec("svc/bare", vec![])); registry } #[test] fn same_registry_yields_byte_identical_docs() { let raw_1 = to_openapi(&sample_registry()).unwrap().raw; let raw_2 = to_openapi(&sample_registry()).unwrap().raw; let a = serde_json::to_string_pretty(&raw_1).unwrap(); let b = serde_json::to_string_pretty(&raw_2).unwrap(); assert_eq!(a, b, "identical registry state → byte-identical doc"); } #[test] fn error_codes_are_sorted_within_each_status_key() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec( "svc/multi", vec![ error("ZULU", Some(418)), error("ALPHA", Some(418)), error("MIKE", Some(418)), ], ), ); let spec = to_openapi(®istry).unwrap(); let responses_map = responses(&spec, PATH_CALL, "post"); let r418 = responses_map.get("418").unwrap(); assert_eq!( code_enum(&spec, response_schema(r418)), vec!["ALPHA", "MIKE", "ZULU"] ); } // --- /publish contract -------------------------------------------------- #[test] fn publish_has_post_method_with_ndjson_request_body() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let request_schema = spec.raw["paths"][PATH_PUBLISH]["post"]["requestBody"]["content"] ["application/x-ndjson"]["schema"] .clone(); assert_eq!( request_schema.get("$ref"), Some(&json!("#/components/schemas/NdjsonBody")), "publish request body is x-ndjson via $ref" ); let description = spec .raw .pointer("/components/schemas/NdjsonBody/description") .and_then(Value::as_str) .unwrap(); assert!(description.contains("operation"), "{description}"); assert!(description.contains("chunk"), "{description}"); } #[test] fn publish_documents_the_full_status_set() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_PUBLISH, "post"); for status in ["200", "400", "401", "403", "404", "422", "500", "504"] { assert!( responses.contains_key(status), "/publish {status} documented" ); } assert!( !responses.contains_key("429") && !responses.contains_key("503"), "no operation-declared statuses on an empty registry" ); } #[test] fn publish_400_and_401_carry_the_identity_split_codes() { 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!(refs_400.contains(&"#/components/schemas/CallErrorInvalidInput".to_string())); 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] fn call_includes_all_protocol_level_error_statuses() { 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", "415", "422", "500", "504", ] { assert!(responses.contains_key(status), "/call {status} documented"); } assert!( !responses.contains_key("429"), "empty registry: no 429 projected" ); assert!( !responses.contains_key("503"), "empty registry: no 503 projected" ); } #[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(); let spec = to_openapi(®istry).unwrap(); let components = &spec.raw["components"]["schemas"]; assert_eq!( components["CallErrorInvalidInput"].pointer("/properties/code/enum/0"), Some(&json!("INVALID_INPUT")) ); assert_eq!( components["CallErrorForbidden"].pointer("/properties/code/enum/0"), Some(&json!("FORBIDDEN")) ); assert_eq!( components["CallErrorTimeout"].pointer("/properties/code/enum/0"), Some(&json!("TIMEOUT")) ); } #[test] fn call_and_publish_500s_include_the_operation_landing_zone() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); for path_name in [PATH_CALL, PATH_PUBLISH] { let responses_map = responses(&spec, path_name, "post"); let r500 = responses_map.get("500").unwrap(); let refs: Vec<&str> = response_schema(r500) .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(); let failure = if path_name == PATH_PUBLISH { "#/components/schemas/PublishFailure" } else { "#/components/schemas/CallFailure" }; assert!( refs.contains(&"#/components/schemas/CallErrorInternal"), "{refs:?}" ); assert!(refs.contains(&failure), "{refs:?}"); assert!(r500 .get("description") .and_then(Value::as_str) .unwrap() .contains("HTTP_")); } assert!( spec.raw["components"]["schemas"] .get("PublishFailure") .is_some(), "publish 500 oneOf references the publish-side failure component" ); } }