1948 lines
79 KiB
Rust
1948 lines
79 KiB
Rust
//! `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_<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.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<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."),
|
|
"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<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_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::<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_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<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 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"
|
|
);
|
|
}
|
|
}
|