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:
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user