Files
alkhttp/src/adapters/to_openapi.rs
T
glm-5.3-flash 42239a0af5 feat(gateway,adapters): /publish endpoint (ADR-068) + to_openapi 6-endpoint projection
gateway-publish:
- GatewayDispatch::invoke_sink (internal:false, forwarded_for:None)
- POST /publish: NDJSON body, first line {operation, chunk} (OQ-02
  resolved: first-line convention; terminal errors = plain HTTP status
  + JSON body, not NDJSON lines); 404 internal/unknown, 401/403 ACL,
  400 INVALID_OPERATION_TYPE for non-Pub
- ADR-068 + open-questions.md updated with the OQ-02 resolution

adapter-to-openapi:
- src/adapters/openapi_spec.rs: OpenAPISpec model (JSON/YAML/from_str
  JSON-first per ADR-051, $ref resolution) shared by from/to_openapi
- src/adapters/to_openapi.rs: 6-endpoint projection, info.version
  1.0.0 -> 1.1.0 (minor: /publish addition per ADR-045), /publish
  NDJSON doc with 400 oneOf (INVALID_INPUT + INVALID_OPERATION_TYPE),
  ADR-023 error fidelity (protocol statuses, HTTP_<status> passthrough,
  internal-op exclusion)
- GET /openapi.json wired into HttpAdapter's router (bearer-auth layer)

Verified: cargo test (136 lib), test --all-features (136+10 WS),
clippy -D warnings (both), fmt. Doc validates against openapiv3.
2026-08-28 13:54:49 +00:00

1109 lines
37 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) — 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_<status>`-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<ErrorDefinition>) -> 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<String, Value> {
let mut responses: Map<String, Value> = 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<String, Value>,
operation_errors: &[ErrorDefinition],
) {
let mut operation_errors_by_status: BTreeMap<u16, Vec<&ErrorDefinition>> = 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: <output>\\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<Value> = 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::<Vec<_>>()
.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<ErrorDefinition> {
let mut by_status: BTreeMap<u16, Vec<ErrorDefinition>> = BTreeMap::new();
let mut seen_codes: std::collections::BTreeSet<String> = 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::<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, 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<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)
.expect("paths object present")
}
fn path<'a>(spec: &'a OpenAPISpec, name: &str) -> &'a Map<String, Value> {
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<String, Value> {
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<String, Value> {
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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<Vec<String>> = 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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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);
}
}