//! `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) — a minor bump (additive). //! //! Error fidelity (ADR-023): `/call`'s and `/publish`'s responses //! include the protocol-level errors (400, 401, 403, 404, 500, 504) //! plus the operation-level errors from the registry's `error_schemas`, //! mapped by `http_status`. `HTTP_`-prefixed codes project to //! their status without colliding with the protocol-level codes. //! //! See `docs/architecture/http-adapters.md` §"to_openapi" and //! ADR-042/045/068/023. use std::collections::BTreeMap; use serde_json::{json, Map, Value}; use alkcall::registry::registration::OperationRegistry; use alkcall::registry::spec::ErrorDefinition; use super::openapi_spec::OpenAPISpec; const GATEWAY_VERSION: &str = "1.1.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_INTERNAL: u16 = 500; const STATUS_TIMEOUT: u16 = 504; 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 HTTP_PREFIX: &str = "HTTP_"; pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec { let operation_errors = collect_operation_errors(registry); let raw = build_doc(operation_errors); OpenAPISpec::from_value(raw).expect("to_openapi always emits a valid OpenAPI document") } fn build_doc(operation_errors: Vec) -> 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": { "schemas": components_schemas() } }) } fn search_path_item() -> Value { json!({ "get": { "operationId": "gatewaySearch", "summary": "List/search operations (AccessControl-filtered). Returns names + descriptions.", "responses": { "200": json_response(schema_search_result()), "401": json_response(schema_unauthorized()), "403": json_response(schema_forbidden()), "500": json_response(schema_internal()), "504": json_response(schema_timeout()) } } }) } fn schema_path_item() -> Value { json!({ "get": { "operationId": "gatewaySchema", "summary": "Get an operation's full OperationSpec (input/output JSON Schemas, error schemas).", "parameters": [ { "name": "name", "in": "query", "required": true, "schema": { "type": "string" } } ], "responses": { "200": json_response(schema_schema_result()), "400": json_response(schema_invalid_input()), "401": json_response(schema_unauthorized()), "403": json_response(schema_forbidden()), "404": json_response(schema_not_found()), "500": json_response(schema_internal()), "504": json_response(schema_timeout()) } } }) } fn call_path_item(operation_errors: &[ErrorDefinition]) -> Value { let mut responses = protocol_error_responses(); responses.insert("200".to_string(), json_response(schema_call_ok())); merge_operation_errors_by_status(&mut responses, operation_errors); json!({ "post": { "operationId": "gatewayCall", "summary": "Invoke an operation by name with a flat JSON input.", "requestBody": { "required": true, "content": { "application/json": { "schema": schema_call_request() } } }, "responses": Value::Object(responses) } }) } fn batch_path_item() -> Value { json!({ "post": { "operationId": "gatewayBatch", "summary": "Invoke multiple operations in one request. Returns an array of results.", "requestBody": { "required": true, "content": { "application/json": { "schema": schema_batch_request() } } }, "responses": { "200": json_response(schema_batch_result()), "400": json_response(schema_invalid_input()), "401": json_response(schema_unauthorized()), "403": json_response(schema_forbidden()), "500": json_response(schema_internal()), "504": json_response(schema_timeout()) } } }) } fn subscribe_path_item() -> Value { json!({ "post": { "operationId": "gatewaySubscribe", "summary": "Invoke a streaming operation. Response is text/event-stream.", "requestBody": { "required": true, "content": { "application/json": { "schema": schema_call_request() } } }, "responses": { "200": sse_response(), "400": json_response(schema_invalid_input()), "401": json_response(schema_unauthorized()), "403": json_response(schema_forbidden()), "404": json_response(schema_not_found()), "500": json_response(schema_internal()), "504": json_response(schema_timeout()) } } }) } fn publish_path_item(operation_errors: &[ErrorDefinition]) -> Value { let mut responses = protocol_error_responses(); responses.insert("200".to_string(), json_response(schema_call_ok())); responses.insert( STATUS_BAD_REQUEST.to_string(), json_response(schema_one_of(&[ schema_protocol_error(CODE_INVALID_INPUT), schema_protocol_error(CODE_INVALID_OPERATION_TYPE), ])), ); merge_operation_errors_by_status(&mut responses, operation_errors); json!({ "post": { "operationId": "gatewayPublish", "summary": "Invoke a Pub operation. The body is NDJSON: the first line carries {\"operation\": ..., \"chunk\": ...}, subsequent lines are chunk values. The final ResponseEnvelope is the response.", "requestBody": { "required": true, "content": { "application/x-ndjson": { "schema": schema_publish_request() } } }, "responses": Value::Object(responses) } }) } fn protocol_error_responses() -> Map { let mut responses: Map = Map::new(); responses.insert( STATUS_BAD_REQUEST.to_string(), json_response(schema_protocol_error(CODE_INVALID_INPUT)), ); responses.insert( STATUS_UNAUTHORIZED.to_string(), json_response(schema_unauthorized()), ); responses.insert( STATUS_FORBIDDEN.to_string(), json_response(schema_protocol_error(CODE_FORBIDDEN)), ); responses.insert( STATUS_NOT_FOUND.to_string(), json_response(schema_protocol_error(CODE_NOT_FOUND)), ); responses.insert( STATUS_INTERNAL.to_string(), json_response(schema_protocol_error(CODE_INTERNAL)), ); responses.insert( STATUS_TIMEOUT.to_string(), json_response(schema_protocol_error(CODE_TIMEOUT)), ); responses } fn merge_operation_errors_by_status( responses: &mut Map, operation_errors: &[ErrorDefinition], ) { let mut operation_errors_by_status: BTreeMap> = BTreeMap::new(); for error in operation_errors { let status = match error.http_status { Some(status) => status, None => continue, }; operation_errors_by_status .entry(status) .or_default() .push(error); } for (status, errors) in operation_errors_by_status { let key = status.to_string(); let response = responses .entry(key) .or_insert_with(|| json_response(Value::Null)); merge_operation_errors(response, &errors); } } fn schema_call_request() -> Value { json!({ "type": "object", "properties": { "operation": { "type": "string", "description": "The fully-qualified operation name to invoke." }, "input": { "type": "object", "description": "The JSON input object to pass to the operation." } }, "required": ["operation"] }) } fn schema_publish_request() -> Value { json!({ "type": "string", "description": "NDJSON: the first line is {\"operation\": \"/service/op\", \"chunk\": {...}}; subsequent lines are chunk values (one published chunk per line)." }) } fn schema_batch_request() -> Value { json!({ "type": "array", "items": schema_call_request() }) } fn schema_call_ok() -> Value { json!({ "type": "object", "properties": { "request_id": { "type": "string" }, "result": { "type": "string", "enum": ["ok"] }, "output": { "type": "object", "description": "The operation's output." } }, "required": ["request_id", "result", "output"] }) } fn schema_search_result() -> Value { json!({ "type": "object", "properties": { "operations": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string" } } } } } }) } fn schema_schema_result() -> Value { json!({ "type": "object", "properties": { "name": { "type": "string" }, "namespace": { "type": "string" }, "input_schema": { "type": "object" }, "output_schema": { "type": "object" }, "error_schemas": { "type": "array", "items": { "type": "object" } } } }) } fn schema_batch_result() -> Value { json!({ "type": "object", "properties": { "results": { "type": "array", "items": { "type": "object", "properties": { "request_id": { "type": "string" }, "result": { "type": "string", "enum": ["ok", "error"] }, "output": { "type": "object" }, "error": { "type": "object" } } } } } }) } fn schema_invalid_input() -> Value { schema_protocol_error(CODE_INVALID_INPUT) } fn schema_unauthorized() -> Value { json!({ "type": "object", "properties": { "code": { "type": "string", "enum": ["FORBIDDEN"] }, "message": { "type": "string", "description": "Authentication required (no bearer token)." }, "retryable": { "type": "boolean" } }, "required": ["code", "message", "retryable"] }) } fn schema_forbidden() -> Value { schema_protocol_error(CODE_FORBIDDEN) } fn schema_not_found() -> Value { schema_protocol_error(CODE_NOT_FOUND) } fn schema_internal() -> Value { schema_protocol_error(CODE_INTERNAL) } fn schema_timeout() -> Value { schema_protocol_error(CODE_TIMEOUT) } fn schema_protocol_error(code: &str) -> Value { json!({ "type": "object", "properties": { "code": { "type": "string", "enum": [code] }, "message": { "type": "string" }, "retryable": { "type": "boolean" } }, "required": ["code", "message", "retryable"] }) } fn schema_one_of(schemas: &[Value]) -> Value { json!({ "oneOf": schemas }) } fn operation_error_schema(error: &ErrorDefinition) -> Value { let mut schema = if error.schema.is_object() { error.schema.clone() } else { json!({ "type": "object" }) }; let obj = schema.as_object_mut().expect("error schema is object"); obj.entry("title") .or_insert(Value::String(error.code.clone())); obj.entry("description") .or_insert(Value::String(error.description.clone())); schema } fn json_response(schema: Value) -> Value { json!({ "description": "", "content": { "application/json": { "schema": schema } } }) } fn sse_response() -> Value { json!({ "description": "Server-Sent Events stream. Each `data:` frame is a call.responded event; stream close is call.completed.", "content": { "text/event-stream": { "schema": { "type": "string", "description": "SSE frame: `data: \\n\\n`." } } } }) } fn merge_operation_errors(response: &mut Value, errors: &[&ErrorDefinition]) { let obj = match response.as_object_mut() { Some(obj) => obj, None => return, }; let content = obj .entry("content".to_string()) .or_insert(json!({})) .as_object_mut(); let content = match content { Some(c) => c, None => return, }; let json_entry = content .entry("application/json".to_string()) .or_insert(json!({})) .as_object_mut(); let json_entry = match json_entry { Some(j) => j, None => return, }; let existing_schema = json_entry.get("schema").cloned(); let op_schemas: Vec = errors.iter().map(|e| operation_error_schema(e)).collect(); let merged = match existing_schema { Some(existing) if !existing.is_null() => { let mut variants = vec![existing]; for s in op_schemas { if !variant_already_present(&variants, &s) { variants.push(s); } } if variants.len() == 1 { variants.into_iter().next().unwrap() } else { json!({ "oneOf": variants }) } } _ => { if op_schemas.len() == 1 { op_schemas.into_iter().next().unwrap() } else { json!({ "oneOf": op_schemas }) } } }; json_entry.insert("schema".to_string(), merged); let description = errors .iter() .map(|e| format!("{}: {}", e.code, e.description)) .collect::>() .join("; "); obj.insert("description".to_string(), Value::String(description)); } fn variant_already_present(variants: &[Value], candidate: &Value) -> bool { variants.iter().any(|v| { v.get("title").and_then(Value::as_str) == candidate.get("title").and_then(Value::as_str) }) } fn components_schemas() -> Value { json!({ "CallRequest": schema_call_request(), "CallOk": schema_call_ok(), "SearchResult": schema_search_result(), "SchemaResult": schema_schema_result(), "BatchResult": schema_batch_result() }) } fn collect_operation_errors(registry: &OperationRegistry) -> Vec { let mut by_status: BTreeMap> = BTreeMap::new(); let mut seen_codes: std::collections::BTreeSet = std::collections::BTreeSet::new(); for spec in registry.list_operations() { for error in &spec.error_schemas { let status = match error.http_status { Some(status) => status, None => continue, }; if is_protocol_status(status) && !is_http_prefixed_code(&error.code) { continue; } if !seen_codes.insert(error.code.clone()) { continue; } by_status.entry(status).or_default().push(error.clone()); } } by_status.into_values().flatten().collect() } fn is_protocol_status(status: u16) -> bool { matches!( status, STATUS_BAD_REQUEST | STATUS_UNAUTHORIZED | STATUS_FORBIDDEN | STATUS_NOT_FOUND | STATUS_INTERNAL | STATUS_TIMEOUT ) } fn is_http_prefixed_code(code: &str) -> bool { code.starts_with(HTTP_PREFIX) && code[HTTP_PREFIX.len()..].parse::().is_ok() } #[cfg(test)] mod tests { use super::*; use alkcall::core::types::Capabilities; use alkcall::protocol::wire::ResponseEnvelope; use alkcall::registry::registration::{ make_handler, HandlerKind, HandlerRegistration, OperationProvenance, }; use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; use serde_json::json; fn noop_handler() -> alkcall::registry::registration::Handler { make_handler(|_input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, Value::Null) }) } fn register(registry: &mut OperationRegistry, spec: OperationSpec) { registry .register(HandlerRegistration::new( spec, HandlerKind::Once(noop_handler()), OperationProvenance::Local, None, None, Capabilities::new(), )) .unwrap(); } fn external_spec(name: &str, errors: Vec) -> OperationSpec { OperationSpec::new( name, OperationType::Query, Visibility::External, json!({}), json!({}), errors, AccessControl::default(), None, ) } fn error(code: &str, http_status: Option) -> ErrorDefinition { ErrorDefinition { code: code.to_string(), description: format!("error {code}"), schema: json!({ "type": "object" }), http_status, } } fn paths_object(spec: &OpenAPISpec) -> &Map { spec.raw .get("paths") .and_then(Value::as_object) .expect("paths object present") } fn path<'a>(spec: &'a OpenAPISpec, name: &str) -> &'a Map { paths_object(spec) .get(name) .and_then(Value::as_object) .unwrap_or_else(|| panic!("path {name} present")) } fn operation<'a>(spec: &'a OpenAPISpec, name: &str, method: &str) -> &'a Map { path(spec, name) .get(method) .and_then(Value::as_object) .unwrap_or_else(|| panic!("operation {method} {name} present")) } fn responses<'a>(spec: &'a OpenAPISpec, name: &str, method: &str) -> &'a Map { operation(spec, name, method) .get("responses") .and_then(Value::as_object) .expect("responses present") } #[test] fn empty_registry_produces_six_gateway_paths() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let paths = paths_object(&spec); assert_eq!(paths.len(), 6); assert!(paths.contains_key(PATH_SEARCH)); assert!(paths.contains_key(PATH_SCHEMA)); assert!(paths.contains_key(PATH_CALL)); assert!(paths.contains_key(PATH_BATCH)); assert!(paths.contains_key(PATH_SUBSCRIBE)); assert!(paths.contains_key(PATH_PUBLISH)); } #[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); 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_1_0_after_publish_addition() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let version = spec .raw .get("info") .and_then(|i: &Value| i.get("version")) .and_then(Value::as_str) .unwrap(); assert_eq!(version, GATEWAY_VERSION); assert_eq!( version, "1.1.0", "minor bump for the /publish addition (ADR-068, ADR-045)" ); } #[test] fn info_title_present() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let title = spec .raw .get("info") .and_then(|i: &Value| i.get("title")) .and_then(Value::as_str) .unwrap(); assert_eq!(title, GATEWAY_TITLE); } #[test] fn openapi_field_is_3_0_0() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let openapi = spec.raw.get("openapi").and_then(Value::as_str).unwrap(); assert_eq!(openapi, OPENAPI_VERSION); } #[test] fn publish_has_post_method_with_ndjson_request_body() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); assert!(path(&spec, PATH_PUBLISH).contains_key("post")); let request_schema = operation(&spec, PATH_PUBLISH, "post") .get("requestBody") .and_then(|rb| rb.get("content")) .and_then(|c: &Value| c.get("application/x-ndjson")) .and_then(|c: &Value| c.get("schema")) .expect("publish request body is x-ndjson"); assert_eq!( request_schema.get("type").and_then(Value::as_str), Some("string"), "NDJSON body is a string payload" ); let description = request_schema .get("description") .and_then(Value::as_str) .unwrap(); assert!(description.contains("operation"), "{description}"); assert!(description.contains("chunk"), "{description}"); } #[test] fn publish_includes_protocol_error_statuses_including_400() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_PUBLISH, "post"); for status in [ STATUS_BAD_REQUEST, STATUS_UNAUTHORIZED, STATUS_FORBIDDEN, STATUS_NOT_FOUND, STATUS_INTERNAL, STATUS_TIMEOUT, ] { assert!( responses.contains_key(&status.to_string()), "protocol status {status} present on /publish" ); } assert!(responses.contains_key("200")); } #[test] fn publish_400_response_covers_invalid_input_and_invalid_operation_type() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_PUBLISH, "post"); let schema = responses .get(&STATUS_BAD_REQUEST.to_string()) .and_then(|r: &Value| r.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let one_of = schema.get("oneOf").and_then(Value::as_array).unwrap(); let codes: Vec> = one_of .iter() .filter_map(|v: &Value| { v.get("properties") .and_then(|p| p.get("code")) .and_then(|c| c.get("enum")) .and_then(Value::as_array) .map(|arr| { arr.iter() .filter_map(|x| x.as_str().map(str::to_string)) .collect() }) }) .collect(); let flat: Vec<&str> = codes.iter().map(|c| c[0].as_str()).collect(); assert!(flat.contains(&CODE_INVALID_INPUT), "{flat:?}"); assert!(flat.contains(&CODE_INVALID_OPERATION_TYPE), "{flat:?}"); } #[test] fn call_request_body_is_flat_operation_input() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let request_schema = operation(&spec, PATH_CALL, "post") .get("requestBody") .and_then(|rb| rb.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let props = request_schema .get("properties") .and_then(Value::as_object) .unwrap(); assert!(props.contains_key("operation")); assert!(props.contains_key("input")); let operation_prop = props.get("operation").unwrap(); assert_eq!( operation_prop.get("type").and_then(Value::as_str), Some("string") ); let input_prop = props.get("input").unwrap(); assert_eq!( input_prop.get("type").and_then(Value::as_str), Some("object") ); let required = request_schema .get("required") .and_then(Value::as_array) .unwrap(); assert!(required.iter().any(|v| v == "operation")); } #[test] fn call_includes_all_protocol_level_error_statuses() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); for status in [ STATUS_BAD_REQUEST, STATUS_UNAUTHORIZED, STATUS_FORBIDDEN, STATUS_NOT_FOUND, STATUS_INTERNAL, STATUS_TIMEOUT, ] { assert!( responses.contains_key(&status.to_string()), "protocol status {status} present on /call" ); } assert!(responses.contains_key("200")); } #[test] fn call_protocol_error_status_codes_have_protocol_codes() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); let invalid_input_schema = responses .get(&STATUS_BAD_REQUEST.to_string()) .and_then(|r: &Value| r.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .and_then(|s: &Value| s.get("properties")) .and_then(|p: &Value| p.get("code")) .and_then(|c: &Value| c.get("enum")) .and_then(Value::as_array) .unwrap(); assert_eq!(invalid_input_schema[0], CODE_INVALID_INPUT); let forbidden_schema = responses .get(&STATUS_FORBIDDEN.to_string()) .and_then(|r: &Value| r.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .and_then(|s: &Value| s.get("properties")) .and_then(|p: &Value| p.get("code")) .and_then(|c: &Value| c.get("enum")) .and_then(Value::as_array) .unwrap(); assert_eq!(forbidden_schema[0], CODE_FORBIDDEN); let timeout_schema = responses .get(&STATUS_TIMEOUT.to_string()) .and_then(|r: &Value| r.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .and_then(|s: &Value| s.get("properties")) .and_then(|p: &Value| p.get("code")) .and_then(|c: &Value| c.get("enum")) .and_then(Value::as_array) .unwrap(); assert_eq!(timeout_schema[0], CODE_TIMEOUT); } #[test] fn operation_errors_projected_onto_call() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec( "fs/readFile", vec![ error("FILE_NOT_FOUND", Some(404)), error("RATE_LIMITED", Some(429)), ], ), ); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); assert!( responses.contains_key("429"), "operation-level 429 projected onto /call" ); let response_429 = responses.get("429").unwrap(); let schema = response_429 .get("content") .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let title = schema.get("title").and_then(Value::as_str).unwrap(); assert_eq!(title, "RATE_LIMITED"); let description = response_429 .get("description") .and_then(Value::as_str) .unwrap(); assert!(description.contains("RATE_LIMITED")); } #[test] fn http_prefixed_error_code_projects_to_status() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("svc/op", vec![error("HTTP_404", Some(404))]), ); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); let response_404 = responses.get("404").unwrap(); let schema = response_404 .get("content") .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let one_of = schema.get("oneOf").and_then(Value::as_array); let titles: Vec<&str> = match one_of { Some(arr) => arr .iter() .filter_map(|v: &Value| v.get("title").and_then(Value::as_str)) .collect(), None => vec![schema.get("title").and_then(Value::as_str).unwrap_or("")], }; assert!( titles.contains(&"HTTP_404"), "HTTP_404 operation error must be projected on /call 404, got titles: {titles:?}" ); } #[test] fn http_prefixed_code_does_not_collide_with_protocol_code() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("svc/op", vec![error("HTTP_404", Some(404))]), ); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); let response_404 = responses.get("404").unwrap(); let schema = response_404 .get("content") .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let one_of = schema.get("oneOf").and_then(Value::as_array); let variants: Vec<&Value> = match one_of { Some(arr) => arr.iter().collect(), None => vec![schema], }; let http_404_variant = variants .iter() .find(|v| v.get("title").and_then(Value::as_str) == Some("HTTP_404")) .expect("HTTP_404 variant present"); let http_enum = http_404_variant .get("properties") .and_then(|p: &Value| p.get("code")) .and_then(|c: &Value| c.get("enum")) .and_then(Value::as_array); assert!( http_enum.is_none(), "HTTP_404 variant is not constrained to a protocol code enum" ); let titles: Vec<&str> = variants .iter() .filter_map(|v| v.get("title").and_then(Value::as_str)) .collect(); assert!( titles.contains(&"HTTP_404"), "HTTP_404 operation error projected alongside protocol 404, got titles: {titles:?}" ); } #[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); let responses = responses(&spec, PATH_CALL, "post"); assert!( responses.len() < 10, "no status-less error projected: {responses:?}" ); } #[test] fn duplicate_error_status_surfaces_all_distinct_codes() { 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); let responses = responses(&spec, PATH_CALL, "post"); assert!(responses.contains_key("429")); let schema = responses .get("429") .and_then(|r: &Value| r.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let one_of = schema.get("oneOf").and_then(Value::as_array).unwrap(); let titles: Vec<&str> = one_of .iter() .filter_map(|v| v.get("title").and_then(Value::as_str)) .collect(); assert!(titles.contains(&"RATE_LIMITED")); assert!(titles.contains(&"TOO_MANY_REQUESTS")); } #[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); let responses = responses(&spec, PATH_CALL, "post"); assert!( !responses.contains_key("418"), "internal op errors not projected" ); } #[test] fn operation_error_with_protocol_status_but_http_prefix_is_projected() { let mut registry = OperationRegistry::new(); register( &mut registry, external_spec("svc/op", vec![error("HTTP_500", Some(500))]), ); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); let schema = responses .get("500") .and_then(|r: &Value| r.get("content")) .and_then(|c: &Value| c.get("application/json")) .and_then(|c: &Value| c.get("schema")) .unwrap(); let one_of = schema.get("oneOf").and_then(Value::as_array).unwrap(); let titles: Vec<&str> = one_of .iter() .filter_map(|v| v.get("title").and_then(Value::as_str)) .collect(); assert!(titles.contains(&"HTTP_500")); } #[test] fn doc_validates_against_openapiv3_parsing() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); 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); } }