diff --git a/crates/alknet-http/src/adapters/mod.rs b/crates/alknet-http/src/adapters/mod.rs index 9d8c95a..3744326 100644 --- a/crates/alknet-http/src/adapters/mod.rs +++ b/crates/alknet-http/src/adapters/mod.rs @@ -16,7 +16,10 @@ pub mod from_mcp; #[cfg(feature = "mcp")] pub mod to_mcp; +pub mod to_openapi; + pub use from_openapi::{FromOpenAPI, HttpAuthScheme, HttpServiceConfig, OpenAPISpec}; +pub use to_openapi::to_openapi; #[cfg(feature = "mcp")] pub use from_mcp::FromMCP; diff --git a/crates/alknet-http/src/adapters/to_openapi.rs b/crates/alknet-http/src/adapters/to_openapi.rs new file mode 100644 index 0000000..753aa81 --- /dev/null +++ b/crates/alknet-http/src/adapters/to_openapi.rs @@ -0,0 +1,1052 @@ +//! `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, 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, + noop_handler(), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )); + } + + 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(), + ), + noop_handler(), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + )); + 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")); + } +} diff --git a/crates/alknet-http/src/server/adapter.rs b/crates/alknet-http/src/server/adapter.rs index 0e80839..5c2d7ab 100644 --- a/crates/alknet-http/src/server/adapter.rs +++ b/crates/alknet-http/src/server/adapter.rs @@ -5,8 +5,8 @@ //! custom routes + decoy fallback) and drives hyper's HTTP/1.1 or HTTP/2 //! connection driver over a single QUIC bidirectional stream. The 5 gateway //! endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are wired in -//! from `gateway_routes`; `/openapi.json`, the MCP route, and the WS upgrade -//! handler remain placeholder 501 handlers pending their respective tasks. +//! from `gateway_routes`; `/openapi.json` serves the `to_openapi` projection +//! of the registry. use std::io; use std::path::PathBuf; @@ -14,6 +14,7 @@ use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; +use axum::extract::State; use axum::http::StatusCode; use axum::middleware::from_fn_with_state; use axum::response::IntoResponse; @@ -35,6 +36,7 @@ use super::gateway_routes; use super::healthz::healthz; #[cfg(feature = "mcp")] use crate::adapters::to_mcp_service; +use crate::adapters::to_openapi; #[cfg(feature = "mcp")] use crate::gateway::GatewayDispatch; use crate::websocket::upgrade::ws_upgrade_handler; @@ -183,7 +185,7 @@ fn build_router(state: RouterState, extra_routes: Option) -> Router { let default: Router = Router::new() .merge(gateway_routes::gateway_router()) - .route("/openapi.json", get(not_implemented)) + .route("/openapi.json", get(openapi_json_handler)) .route(WS_UPGRADE_PATH, get(ws_upgrade_handler)) .route_layer(from_fn_with_state( auth_state.clone(), @@ -204,8 +206,16 @@ fn build_router(state: RouterState, extra_routes: Option) -> Router { with_extras.with_state(state) } -async fn not_implemented() -> impl IntoResponse { - (StatusCode::NOT_IMPLEMENTED, "501 Not Implemented") +async fn openapi_json_handler(State(registry): State>) -> impl IntoResponse { + let spec = to_openapi(®istry); + ( + StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + )], + axum::Json(spec.raw), + ) } #[async_trait] @@ -684,4 +694,23 @@ mod tests { ); assert!(response.contains("location: https://example.com")); } + + + #[tokio::test] + async fn openapi_json_route_serves_gateway_spec() { + let adapter = HttpAdapter::new(provider(), empty_registry()); + let request = b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let response = serve_and_read(adapter, request).await; + assert!( + response.starts_with("HTTP/1.1 200"), + "expected 200 for /openapi.json, got: {response}" + ); + assert!(response.contains("\"openapi\"")); + assert!(response.contains("\"/search\"")); + assert!(response.contains("\"/schema\"")); + assert!(response.contains("\"/call\"")); + assert!(response.contains("\"/batch\"")); + assert!(response.contains("\"/subscribe\"")); + assert!(response.contains("\"1.0.0\"")); + } }