//! `to_openapi`: gateway projection of the local operation registry into a //! fixed 5-endpoint OpenAPI 3.0 document (ADR-042). //! //! `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 5 fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`, //! `/subscribe`) — 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). The initial version is `1.0.0`. //! //! Error fidelity (ADR-023): `/call`'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/crates/http/http-adapters.md` §"to_openapi" and //! ADR-042/045/023. use std::collections::BTreeMap; use serde_json::{json, Map, Value}; use alknet_call::registry::registration::OperationRegistry; use alknet_call::registry::spec::ErrorDefinition; use super::from_openapi::OpenAPISpec; const GATEWAY_VERSION: &str = "1.0.0"; const GATEWAY_TITLE: &str = "alknet 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 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_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(), }); json!({ "openapi": OPENAPI_VERSION, "info": { "title": GATEWAY_TITLE, "version": GATEWAY_VERSION, "description": "alknet gateway: 5 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: Map = serde_json::Map::new(); responses.insert("200".to_string(), json_response(schema_call_ok())); 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)), ); 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); } 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 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_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", "description": "The full OperationSpec for the requested operation.", "properties": { "name": { "type": "string" }, "namespace": { "type": "string" }, "op_type": { "type": "string", "enum": ["query", "mutation", "subscription"] }, "input_schema": { "type": "object" }, "output_schema": { "type": "object" }, "error_schemas": { "type": "array" } } }) } 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 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 alknet_call::protocol::wire::ResponseEnvelope; use alknet_call::registry::registration::{ make_handler, HandlerKind, HandlerRegistration, OperationProvenance, }; use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; use alknet_core::types::Capabilities; use serde_json::{json, Map}; fn noop_handler() -> alknet_call::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(), ) } 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_five_gateway_paths() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let paths = paths_object(&spec); assert_eq!(paths.len(), 5); 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)); } #[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(), 5); assert!(!paths.contains_key("/fs/readFile")); assert!(!paths.contains_key("/agent/chat")); } #[test] fn info_version_is_1_0_0() { 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.0.0"); } #[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 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("DOMAIN_ERROR", None)]), ); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_CALL, "post"); assert!(!responses.contains_key("0")); assert_eq!( responses.len(), 7, "only protocol-level statuses + 200 present" ); } #[test] fn subscribe_response_is_text_event_stream() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let responses = responses(&spec, PATH_SUBSCRIBE, "post"); let ok = responses.get("200").unwrap(); let content = ok.get("content").and_then(Value::as_object).unwrap(); assert!(content.contains_key("text/event-stream")); assert!(!content.contains_key("application/json")); } #[test] fn subscribe_request_body_is_flat_operation_input() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let request_schema = operation(&spec, PATH_SUBSCRIBE, "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")); } #[test] fn batch_request_body_is_array_of_call_requests() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let request_schema = operation(&spec, PATH_BATCH, "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(); assert_eq!( request_schema.get("type").and_then(Value::as_str), Some("array") ); let items = request_schema .get("items") .and_then(Value::as_object) .unwrap(); assert!(items.contains_key("properties")); } #[test] fn search_has_get_method() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); assert!(path(&spec, PATH_SEARCH).contains_key("get")); assert!(!path(&spec, PATH_SEARCH).contains_key("post")); } #[test] fn schema_has_get_method_with_name_query_param() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); let params = operation(&spec, PATH_SCHEMA, "get") .get("parameters") .and_then(Value::as_array) .unwrap(); let name_param = ¶ms[0]; assert_eq!(name_param.get("name").and_then(Value::as_str), Some("name")); assert_eq!(name_param.get("in").and_then(Value::as_str), Some("query")); assert_eq!( name_param.get("required").and_then(Value::as_bool), Some(true) ); } #[test] fn call_has_post_method() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry); assert!(path(&spec, PATH_CALL).contains_key("post")); assert!(!path(&spec, PATH_CALL).contains_key("get")); } #[test] fn to_openapi_is_pure_projection_does_not_modify_registry() { let mut registry = OperationRegistry::new(); register(&mut registry, external_spec("fs/readFile", vec![])); let before_count = registry.list_operations().len(); let _ = to_openapi(®istry); assert_eq!(registry.list_operations().len(), before_count); assert!(registry.registration("fs/readFile").is_some()); } #[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(), ), 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")); } }