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.
This commit is contained in:
2026-08-28 13:54:49 +00:00
parent ad975408e7
commit 42239a0af5
10 changed files with 2001 additions and 29 deletions
+4
View File
@@ -5,6 +5,10 @@
pub mod forward;
pub mod from_jsonschema;
pub mod openapi_spec;
pub mod to_openapi;
pub use forward::{HttpAuthScheme, HttpServiceConfig};
pub use from_jsonschema::FromJsonSchema;
pub use openapi_spec::OpenAPISpec;
pub use to_openapi::to_openapi;
+287
View File
@@ -0,0 +1,287 @@
//! `OpenAPISpec` — the parsed OpenAPI 3.x document model shared by the
//! `from_openapi` adapter (consume) and the `to_openapi` projection
//! (produce). JSON + YAML parsing (ADR-051: `yaml_serde` 0.10.x is YAML
//! 1.2; JSON-first detection under `from_str`), plus `$ref` resolution
//! against the raw document.
use std::collections::{BTreeMap, HashMap};
use alkcall::client::AdapterError;
use serde_json::Value;
pub(crate) const HTTP_METHODS: &[&str] =
&["get", "post", "put", "patch", "delete", "head", "options"];
#[derive(Clone, Debug)]
pub struct OpenAPIInfo {
pub title: String,
pub version: String,
}
#[derive(Clone, Debug)]
pub struct PathItem {
pub operations: Vec<(String, Operation)>,
}
#[derive(Clone, Debug)]
pub struct Operation {
pub operation_id: Option<String>,
pub parameters: Vec<Parameter>,
pub request_body: Option<RequestBody>,
pub responses: BTreeMap<String, Response>,
}
#[derive(Clone, Debug)]
pub struct Parameter {
pub name: String,
pub in_: String,
pub required: bool,
pub schema: Option<Value>,
}
#[derive(Clone, Debug)]
pub struct RequestBody {
pub content: BTreeMap<String, Value>,
}
#[derive(Clone, Debug)]
pub struct Response {
pub content: BTreeMap<String, Value>,
}
#[derive(Clone, Debug)]
pub struct Components {
pub schemas: HashMap<String, Value>,
}
#[derive(Debug)]
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
pub components: Option<Components>,
pub raw: Value,
}
impl OpenAPISpec {
pub fn from_json(doc: &str) -> Result<Self, AdapterError> {
let raw: Value = serde_json::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid JSON: {e}"),
})?;
Self::from_value(raw)
}
/// Parse a YAML OpenAPI document.
///
/// The caller has declared the format, so this does not attempt JSON
/// first — whatever type interpretation the YAML parser's schema
/// applies is what the caller gets (see ADR-051 §2). YAML is parsed
/// to a `serde_json::Value` and then fed through
/// [`from_value`](Self::from_value), so there is one internal
/// `OpenAPISpec` representation shared with the JSON path.
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError> {
let raw: Value = yaml_serde::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid YAML: {e}"),
})?;
Self::from_value(raw)
}
/// Parse a raw OpenAPI document of unknown format.
///
/// Detection is **JSON-first, YAML-fallback** (ADR-051 §2). JSON's
/// stricter grammar is immune to any YAML-specific type
/// interpretation, so a JSON doc never reaches the YAML parser under
/// `from_str`. This is a defensive default: `yaml_serde` 0.10.x
/// implements the YAML 1.2 core schema (bare `yes`/`no`/`on`/`off`
/// are strings, not booleans), so the coercion hazard is not present
/// with this dependency version — but JSON-first locks the contract
/// against a future YAML-parser swap (e.g., to a YAML 1.1 crate where
/// those tokens coerce to booleans). A YAML-only document (no JSON
/// braces) fails JSON parse immediately and goes to the YAML path.
#[allow(
clippy::should_implement_trait,
reason = "ADR-051 §1 names this an inherent constructor `from_str`, not a FromStr impl"
)]
pub fn from_str(doc: &str) -> Result<Self, AdapterError> {
match serde_json::from_str::<Value>(doc) {
Ok(raw) => Self::from_value(raw),
Err(_) => Self::from_yaml(doc),
}
}
pub fn from_value(raw: Value) -> Result<Self, AdapterError> {
if !raw.is_object() {
return Err(AdapterError::SchemaParse {
message: "OpenAPI document must be a JSON object".into(),
});
}
let info_obj = raw.get("info").ok_or_else(|| AdapterError::SchemaParse {
message: "OpenAPI document missing `info`".into(),
})?;
let info = OpenAPIInfo {
title: info_obj
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
version: info_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("1.0.0")
.to_string(),
};
let paths_raw = raw.get("paths").ok_or_else(|| AdapterError::SchemaParse {
message: "OpenAPI document missing `paths`".into(),
})?;
if !paths_raw.is_object() {
return Err(AdapterError::SchemaParse {
message: "`paths` must be a JSON object".into(),
});
}
let mut paths = BTreeMap::new();
for (path, item) in paths_raw.as_object().expect("paths is object") {
if !item.is_object() {
continue;
}
let mut operations = Vec::new();
for method in HTTP_METHODS {
if let Some(op_raw) = item.get(*method) {
if let Some(op) = parse_operation(op_raw) {
operations.push((method.to_string(), op));
}
}
}
if operations.is_empty() {
continue;
}
paths.insert(path.clone(), PathItem { operations });
}
let components = raw
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|schemas| {
if !schemas.is_object() {
return None;
}
let mut map = HashMap::new();
for (k, v) in schemas.as_object().expect("schemas is object") {
map.insert(k.clone(), v.clone());
}
Some(Components { schemas: map })
});
Ok(Self {
info,
paths,
components,
raw,
})
}
#[allow(dead_code, reason = "consumed by the from_openapi port (next task)")]
pub(crate) fn resolve_ref(&self, reference: &str) -> Result<Value, AdapterError> {
if !reference.starts_with("#/") {
return Err(AdapterError::SchemaParse {
message: format!("external $ref not supported: {reference}"),
});
}
let mut current: &Value = &self.raw;
for part in reference.trim_start_matches("#/").split('/') {
current = current.get(part).ok_or_else(|| AdapterError::SchemaParse {
message: format!("cannot resolve $ref: {reference}"),
})?;
}
Ok(current.clone())
}
#[allow(dead_code, reason = "consumed by the from_openapi port (next task)")]
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
match schema {
Value::Object(obj) => {
if let Some(Value::String(reference)) = obj.get("$ref") {
let resolved = self.resolve_ref(reference)?;
return self.resolve_refs_recursive(&resolved);
}
let mut out = serde_json::Map::new();
for (k, v) in obj {
out.insert(k.clone(), self.resolve_refs_recursive(v)?);
}
Ok(Value::Object(out))
}
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
out.push(self.resolve_refs_recursive(v)?);
}
Ok(Value::Array(out))
}
other => Ok(other.clone()),
}
}
}
fn parse_operation(raw: &Value) -> Option<Operation> {
if !raw.is_object() {
return None;
}
let operation_id = raw
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let parameters = raw
.get("parameters")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let in_ = p.get("in")?.as_str()?.to_string();
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let schema = p.get("schema").cloned();
Some(Parameter {
name,
in_,
required,
schema,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let request_body = raw.get("requestBody").and_then(|rb| {
let content_obj = rb.get("content")?.as_object()?;
let mut content = BTreeMap::new();
for (k, v) in content_obj {
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
content.insert(k.clone(), schema);
}
Some(RequestBody { content })
});
let mut responses = BTreeMap::new();
if let Some(resp_obj) = raw.get("responses").and_then(|v| v.as_object()) {
for (code, body) in resp_obj {
let content_obj = body.get("content").and_then(|v| v.as_object());
let mut content = BTreeMap::new();
if let Some(content_obj) = content_obj {
for (k, v) in content_obj {
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
content.insert(k.clone(), schema);
}
}
responses.insert(code.clone(), Response { content });
}
}
Some(Operation {
operation_id,
parameters,
request_body,
responses,
})
}
File diff suppressed because it is too large Load Diff