//! `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. `1.4.0` is the GW-16 status
//! unification: the runtime's hand-rolled `/publish` framing faults
//! and the `/batch` cap rejection moved from 400 to the documented
//! `INVALID_INPUT → 422` mapping (one error class, one status), so the
//! doc's 400/422 slots moved with the runtime. Clients matching the old
//! 400 slots observed behavior the runtime no longer emits; the 422
//! slots are where `INVALID_INPUT` was already documented, so the
//! client-visible consequence is additive on the documented mapping
//! side and the status drift is gone.
//!
//! # 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_<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.
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.4.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<OpenAPISpec, AdapterError> {
let operation_errors = gather_operation_errors(registry);
let raw = build_doc(&operation_errors);
OpenAPISpec::from_value(raw)
}
fn build_doc(operation_errors: &BTreeMap<u16, Value>) -> 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<u16, Value>) -> 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_<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
/// order is sorted).
fn call_responses(operation_errors: &BTreeMap<u16, Value>) -> 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<u16, Value>) -> 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<u16, Value>) -> Value {
let mut responses = json!({
"200": json_response(ref_schema("CallOk"),
"The operation's final ResponseEnvelope: request_id, result=ok, output."),
"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: NDJSON stream framing faults — empty body, first line missing 'operation'/'chunk', a line exceeding the 2 MiB per-line cap, or a raw body-read failure (INVALID_INPUT, GW-16 unified with the mid-stream status) — plus a later NDJSON line that was not valid JSON, a chunk that failed the operation's publish_schema validation (INVALID_INPUT with details.chunk), or the operation's type is not Pub (INVALID_OPERATION_TYPE, token present). 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."),
"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 sink dispatch exceeded the 30 s gateway deadline (retryable) — the same deadline bounds the Once-op invoke (GW-17)."),
});
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."),
"422": json_response(one_of_refs(&[
"BatchCapExceeded".to_string(),
]),
"Request-level failure: the batch exceeds 100 operations (INVALID_INPUT, JSON; GW-16 unified with the INVALID_INPUT → 422 mapping). 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<u16, Value>) -> 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_<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(&[
"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<u16, Value>) -> 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<Value> = 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<CallRequest>`, `Json<Vec<CallRequest>>`, `Query<SchemaQuery>`)
/// 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<u16, Value> {
let mut by_status: BTreeMap<u16, BTreeMap<String, ErrorDefinition>> = 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<u16, Value> = 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_<status> 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::<Vec<String>>(),
},
"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::<u16>().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<ErrorDefinition>) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
errors,
AccessControl::default(),
None,
)
}
fn error(code: &str, http_status: Option<u16>) -> ErrorDefinition {
ErrorDefinition {
code: code.to_string(),
description: format!("error {code}"),
schema: json!({ "type": "object" }),
http_status,
}
}
fn paths_object(spec: &OpenAPISpec) -> &Map<String, Value> {
spec.raw
.get("paths")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("paths object present"))
}
fn path(spec: &OpenAPISpec, name: &str) -> Map<String, Value> {
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<String, Value> {
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<String> {
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<String> {
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_4_0_after_gw16_status_unification() {
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.4.0",
"minor bump: status-mapping truthfulness corrections — the 422 slots are where INVALID_INPUT was already documented (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::<Vec<_>>()
);
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_422_json_shape_for_the_cap_reject() {
let registry = OperationRegistry::new();
let spec = to_openapi(®istry).unwrap();
let responses = responses(&spec, PATH_BATCH, "post");
assert!(
!responses.contains_key("400"),
"GW-16: the batch cap reject moved to the INVALID_INPUT → 422 mapping"
);
let schema = response_schema(responses.get("422").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<String> = schema
.get("oneOf")
.and_then(Value::as_array)
.expect("PRJ-17: op-declared variants appended to the shared response's oneOf")
.iter()
.filter_map(|v| v.get("$ref").and_then(Value::as_str).map(str::to_string))
.collect();
assert!(
variant_refs.contains(&"#/components/schemas/CallError_HTTP_404".to_string()),
"the HTTP_404 variant is listed alongside the shared response: {variant_refs:?}"
);
assert!(
spec.raw["components"]["schemas"]["CallError_HTTP_404"]
.pointer("/properties/code/enum/0")
.is_some(),
"the merged variant component is defined"
);
}
#[test]
fn http_prefixed_unauthorized_status_appends_to_401_one_of() {
let mut registry = OperationRegistry::new();
register(
&mut registry,
external_spec("svc/op", vec![error("HTTP_401", Some(401))]),
);
let spec = to_openapi(®istry).unwrap();
let responses_map = responses(&spec, PATH_CALL, "post");
let refs = one_of_refs_of(response_schema(responses_map.get("401").unwrap()));
assert!(refs.contains(&"#/components/schemas/CallErrorForbidden".to_string()));
assert!(refs.contains(&"#/components/schemas/CallError_HTTP_401".to_string()));
}
#[test]
fn operation_error_without_http_status_not_projected() {
let mut registry = OperationRegistry::new();
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 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", "401", "403", "404", "422", "500", "504"] {
assert!(
responses.contains_key(status),
"/publish {status} documented"
);
}
assert!(
!responses.contains_key("400"),
"GW-16: /publish framing faults are INVALID_INPUT → 422; no 400 slot remains"
);
assert!(
!responses.contains_key("429") && !responses.contains_key("503"),
"no operation-declared statuses on an empty registry"
);
}
#[test]
fn publish_401_and_422_carry_the_identity_split_codes() {
let registry = OperationRegistry::new();
let spec = to_openapi(®istry).unwrap();
let responses = responses(&spec, PATH_PUBLISH, "post");
let refs_422 = one_of_refs_of(response_schema(&responses["422"]));
assert!(refs_422.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_422_documents_the_unified_framing_and_chunk_contract() {
let registry = OperationRegistry::new();
let spec = to_openapi(®istry).unwrap();
let responses = responses(&spec, PATH_PUBLISH, "post");
let refs_422 = one_of_refs_of(response_schema(&responses["422"]));
assert_eq!(
refs_422,
vec![
"#/components/schemas/CallErrorInvalidInput".to_string(),
"#/components/schemas/CallErrorInvalidOperationType".to_string(),
],
"GW-16: /publish 422 oneOf carries the framing INVALID_INPUT plus INVALID_OPERATION_TYPE"
);
let description = responses["422"]
.get("description")
.and_then(Value::as_str)
.unwrap();
assert!(
description.contains("first line missing"),
"GW-16: the 422 description names the framing faults now mapped 422: {description}"
);
assert!(
!description.contains("reported INVALID_OPERATION_TYPE"),
"the 422 description must not claim the 401-reported condition as a 422 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"
);
}
}