Add the foundational types for ADR-049 streaming handlers: - StreamingHandler, ResponseStream type aliases and HandlerKind enum (Once | Stream) in registration.rs, with make_streaming_handler() helper - CallError::invalid_operation_type() in wire.rs (sixth protocol code, retryable: false) - HandlerRegistration.handler flipped from Handler to HandlerKind; HandlerRegistration::new() now takes HandlerKind - OperationRegistryBuilder absorbs wrapping: with_local/with_leaf/ with_leaf_provenance wrap raw Handler in HandlerKind::Once for Query/Mutation; new with_local_streaming/with_leaf_streaming take a StreamingHandler and wrap in HandlerKind::Stream for Subscription. Builder validates kind matches spec.op_type (mismatch = startup error) - OperationRegistry::register() returns Result<(), String> with a clear mismatch message; all call sites updated to handle the Result - invoke() matches on HandlerKind: Once -> existing path; Stream -> INVALID_OPERATION_TYPE error envelope (guards against silent truncation; invoke_streaming() added in a downstream task) - OverlayOperationEnv::invoke_with_policy matches on HandlerKind: Once -> dispatch; Stream -> INVALID_OPERATION_TYPE (composition is request/response-only) - Migrated every HandlerRegistration::new() construction site (~95) to wrap raw Handler in HandlerKind::Once(handler); the builder sites are handled by the builder-absorbs-wrapping change - Updated two websocket subscription tests that relied on Subscription ops dispatching via invoke() to expect INVALID_OPERATION_TYPE - Added unit tests for invoke/register validation and make_streaming_handler
1057 lines
35 KiB
Rust
1057 lines
35 KiB
Rust
//! `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_<status>`-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<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(),
|
|
});
|
|
|
|
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<String, Value> = 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<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);
|
|
}
|
|
|
|
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: <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 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<ErrorDefinition>) -> OperationSpec {
|
|
OperationSpec::new(
|
|
name,
|
|
OperationType::Query,
|
|
Visibility::External,
|
|
json!({}),
|
|
json!({}),
|
|
errors,
|
|
AccessControl::default(),
|
|
)
|
|
}
|
|
|
|
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_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"));
|
|
}
|
|
}
|