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:
@@ -45,11 +45,11 @@ stream; the operation's final `ResponseEnvelope` is the HTTP response.
|
||||
- Auth: `Authorization: Bearer <token>` — same as every gateway
|
||||
endpoint ([ADR-004](004-auth-as-shared-core.md)).
|
||||
- The target operation is named the same way as `/call` — the body's
|
||||
first line (or a `?operation=` query parameter) carries
|
||||
`{ "operation": "/{service}/{op}", "chunk": {...} }` for the first
|
||||
chunk, with subsequent lines carrying `chunk` values only; see OQ-02
|
||||
for the exact first-line convention before the gateway-spec version
|
||||
bumps.
|
||||
first line carries `{ "operation": "/{service}/{op}", "chunk": {...}`
|
||||
for the first chunk, with subsequent lines carrying `chunk` values
|
||||
only. (OQ-02 resolved: first-line convention, no query parameter, no
|
||||
header; a `?operation=` parameter was rejected because it duplicates
|
||||
the first-line field and complicates curl one-liners for no gain.)
|
||||
|
||||
### Dispatch
|
||||
|
||||
@@ -114,8 +114,10 @@ the doc does not preload operations.
|
||||
indistinguishable from a network error to the server side (the
|
||||
handler sees EOF either way). Callers needing explicit failure
|
||||
semantics use the call protocol (WS channel 0).
|
||||
- OQ-02 (first-line operation-naming convention) must settle before
|
||||
the `/openapi.json` version bumps.
|
||||
- ~~OQ-02 (first-line operation-naming convention) must settle before
|
||||
the `/openapi.json` version bumps~~ — settled: first-line
|
||||
`{operation, chunk}` convention; terminal errors are plain HTTP
|
||||
status + JSON body (not an NDJSON line).
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -40,15 +40,17 @@ with their resolutions; new alkhttp OQs start at OQ-01.
|
||||
### OQ-02: `/publish` body framing details
|
||||
|
||||
- **Origin**: [ADR-068](decisions/068-gateway-publish-endpoint.md)
|
||||
- **Status**: open
|
||||
- **Status**: resolved (implementation: gateway-publish task, 2026-08-28)
|
||||
- **Priority**: medium
|
||||
- **Resolution**: (pending)
|
||||
- **Question**: The exact first-line convention for naming the target
|
||||
operation (first line `{operation, chunk}` vs `?operation=` query
|
||||
parameter vs required header), and where a terminal error envelope
|
||||
lives (final NDJSON line of a JSON error object vs plain HTTP status
|
||||
with JSON body). Must settle before the gateway contract's
|
||||
`info.version` bumps ([ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md)).
|
||||
- **Resolution**: First line of the NDJSON body carries
|
||||
`{ "operation": "/{service}/{op}", "chunk": {...} }`; subsequent
|
||||
lines are chunk values only. Terminal errors are plain HTTP status +
|
||||
JSON body (NOT an NDJSON line) — consistent with every other gateway
|
||||
endpoint's error surface. A `?operation=` query parameter was
|
||||
considered and rejected: it duplicates the first-line field and adds
|
||||
a second way to name the op (two sources of truth) for no curl-ability
|
||||
gain. The first-line convention is the single naming point.
|
||||
Implemented in `src/gateway/routes.rs::publish_handler`.
|
||||
|
||||
### OQ-03: `from_wss` reconnection semantics
|
||||
|
||||
|
||||
@@ -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
@@ -86,6 +86,36 @@ impl GatewayDispatch {
|
||||
.invoke_streaming(&operation_name, input, context)
|
||||
}
|
||||
|
||||
/// Dispatch a `Pub` operation (ADR-046, ADR-068): the
|
||||
/// `publish_stream` is the initiator's chunk stream (one
|
||||
/// `Ok(Value)` per published chunk); the handler's final
|
||||
/// `ResponseEnvelope` is the result. Pre-handler failures
|
||||
/// (not-found, forbidden, non-Pub op) surface as a single error
|
||||
/// envelope — the same envelope the `/publish` route maps to HTTP.
|
||||
pub async fn invoke_sink(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
publish_stream: alkcall::registry::registration::PublishStream,
|
||||
) -> ResponseEnvelope {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
|
||||
self.registry
|
||||
.invoke_sink(&operation_name, input, publish_stream, context)
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_root_context_sink(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
) -> OperationContext {
|
||||
self.build_root_context_inner(request_id, operation_name, identity, false)
|
||||
}
|
||||
|
||||
fn build_root_context(
|
||||
&self,
|
||||
request_id: &str,
|
||||
|
||||
+412
-1
@@ -25,7 +25,7 @@ use serde_json::{json, Value};
|
||||
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use alkcall::registry::spec::{AccessResult, Visibility};
|
||||
use alkcall::registry::spec::{AccessResult, OperationType, Visibility};
|
||||
|
||||
use super::dispatch::GatewayDispatch;
|
||||
use super::error::{call_error_to_http_response, call_error_to_http_status_with_identity};
|
||||
@@ -76,6 +76,7 @@ pub(crate) fn gateway_router() -> Router<RouterState> {
|
||||
.route("/call", post(call_handler))
|
||||
.route("/batch", post(batch_handler))
|
||||
.route("/subscribe", post(subscribe_handler))
|
||||
.route("/publish", post(publish_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -173,6 +174,128 @@ pub(crate) async fn subscribe_handler(
|
||||
|
||||
pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
|
||||
|
||||
/// `POST /publish` (ADR-068): the body is NDJSON — one published chunk
|
||||
/// per line. OQ-02 resolution: the first line carries
|
||||
/// `{ "operation": "/{service}/{op}", "chunk": {...} }` (subsequent
|
||||
/// lines are chunk values only); a terminal error is a plain HTTP
|
||||
/// status + JSON body (not an NDJSON line). A client disconnect drops
|
||||
/// the body stream — the sink (and the handler's `PublishStream`) sees
|
||||
/// EOF, matching call-protocol write-half close semantics.
|
||||
pub(crate) async fn publish_handler(
|
||||
State(state): State<GatewayState>,
|
||||
ResolvedIdentity(identity): ResolvedIdentity,
|
||||
body: axum::body::Bytes,
|
||||
) -> Response {
|
||||
let mut lines = body
|
||||
.split(|b| *b == b'\n')
|
||||
.filter(|l| !l.iter().all(|b| b.is_ascii_whitespace()));
|
||||
let first = match lines.next() {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "empty publish body: expected NDJSON chunks",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// First line: { "operation": "...", "chunk": {...} } — OQ-02.
|
||||
let (operation, first_chunk): (String, Value) = match serde_json::from_slice::<Value>(first) {
|
||||
Ok(v) => {
|
||||
let op = v
|
||||
.get("operation")
|
||||
.and_then(|o| o.as_str())
|
||||
.map(str::to_string);
|
||||
let chunk = v.get("chunk").cloned().unwrap_or(Value::Null);
|
||||
match op {
|
||||
Some(op) => (op, chunk),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "first publish line must carry {\"operation\": ..., \"chunk\": ...}",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": format!("first publish line is not valid JSON: {e}"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if is_internal_op(&state.registry, &operation) {
|
||||
return not_found_response(&operation);
|
||||
}
|
||||
if state
|
||||
.registry
|
||||
.registration(operation.strip_prefix('/').unwrap_or(&operation))
|
||||
.is_none()
|
||||
{
|
||||
return not_found_response(&operation);
|
||||
}
|
||||
if let Some(forbidden) = access_check_for_op(&state.registry, &operation, identity.as_ref()) {
|
||||
return forbidden_response(forbidden, identity.as_ref());
|
||||
}
|
||||
if !is_pub_op(&state.registry, &operation) {
|
||||
return invalid_operation_type_response(&operation);
|
||||
}
|
||||
|
||||
let chunks: Vec<Result<Value, CallError>> = std::iter::once(Ok(first_chunk))
|
||||
.chain(
|
||||
lines.map(|line| match serde_json::from_slice::<Value>(line) {
|
||||
Ok(chunk) => Ok(chunk),
|
||||
Err(e) => Err(CallError::invalid_input(format!(
|
||||
"publish line is not valid JSON: {e}"
|
||||
))),
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let dispatch = state.dispatch();
|
||||
let envelope = dispatch
|
||||
.invoke_sink(
|
||||
identity.clone(),
|
||||
&operation,
|
||||
Value::Null,
|
||||
Box::pin(futures::stream::iter(chunks)),
|
||||
)
|
||||
.await;
|
||||
envelope_to_response(envelope, identity.as_ref())
|
||||
}
|
||||
|
||||
fn is_pub_op(registry: &OperationRegistry, operation: &str) -> bool {
|
||||
let name = operation.strip_prefix('/').unwrap_or(operation);
|
||||
match registry.registration(name) {
|
||||
Some(reg) => reg.spec.op_type == OperationType::Pub,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_operation_type_response(operation: &str) -> Response {
|
||||
let error = CallError::invalid_operation_type(format!(
|
||||
"operation is not a Pub op; /publish requires OperationType::Pub: {operation}"
|
||||
));
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::to_value(&error).unwrap_or(Value::Null)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn subscribe_stream_from_envelope_stream(
|
||||
stream: BoxStream<'static, ResponseEnvelope>,
|
||||
) -> SubscribeStream {
|
||||
@@ -1169,4 +1292,292 @@ mod tests {
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
// --- /publish (ADR-068) -------------------------------------------------
|
||||
|
||||
use alkcall::registry::registration::make_sink_handler;
|
||||
|
||||
fn publish_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/push",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
HandlerKind::Sink(make_sink_handler(|input, ctx, mut chunks| async move {
|
||||
let mut collected: Vec<Value> = Vec::new();
|
||||
use futures::StreamExt;
|
||||
while let Some(chunk) = chunks.next().await {
|
||||
match chunk {
|
||||
Ok(v) => collected.push(v),
|
||||
Err(e) => {
|
||||
return ResponseEnvelope::error(
|
||||
ctx.request_id,
|
||||
CallError::internal(format!("chunk error: {e:?}")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
json!({
|
||||
"count": collected.len(),
|
||||
"chunks": collected,
|
||||
"seed": input,
|
||||
}),
|
||||
)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"secret/pub",
|
||||
OperationType::Pub,
|
||||
Visibility::Internal,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
HandlerKind::Sink(make_sink_handler(|input, ctx, _chunks| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn raw_request(method: &str, uri: &str, body: Vec<u8>) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn ndjson(lines: &[Value]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for l in lines {
|
||||
out.extend_from_slice(serde_json::to_string(l).unwrap().as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_multi_chunk_sink_round_trip_returns_final_envelope() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[
|
||||
json!({ "operation": "ingest/push", "chunk": { "n": 1 } }),
|
||||
json!({ "n": 2 }),
|
||||
json!({ "n": 3 }),
|
||||
]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(resp.get("result"), Some(&json!("ok")));
|
||||
let output = resp.get("output").expect("output");
|
||||
assert_eq!(output["count"], 3);
|
||||
assert_eq!(output["chunks"][0], json!({ "n": 1 }));
|
||||
assert_eq!(output["chunks"][1], json!({ "n": 2 }));
|
||||
assert_eq!(output["chunks"][2], json!({ "n": 3 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_internal_op_returns_404() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[json!({ "operation": "secret/pub", "chunk": {} })]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
|
||||
}
|
||||
|
||||
fn pub_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_unauthorized_restricted_op_returns_403() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec(
|
||||
"ingest/push",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Sink(make_sink_handler(|input, ctx, _chunks| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
||||
StaticIdentityProvider::new()
|
||||
.with_token("user-tok", identity_with_scopes("user", &["user"])),
|
||||
);
|
||||
let router = build_router(Arc::new(registry), provider);
|
||||
let body = ndjson(&[json!({ "operation": "ingest/push", "chunk": {} })]);
|
||||
let (k, v) = auth_header("user-tok");
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/publish")
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.header(k, v)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let _ = body;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_non_pub_op_returns_400_invalid_operation_type() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[json!({ "operation": "echo/run", "chunk": {} })]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body.get("code"), Some(&json!("INVALID_OPERATION_TYPE")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_unknown_op_returns_404() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[json!({ "operation": "no/such", "chunk": {} })]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_missing_operation_in_first_line_returns_400() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[json!({ "chunk": {} })]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_empty_body_returns_400() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let req = raw_request("POST", "/publish", Vec::new());
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_invalid_later_line_yields_handler_chunk_error() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let mut body = ndjson(&[json!({ "operation": "ingest/push", "chunk": { "n": 1 } })]);
|
||||
body.extend_from_slice(b"not-json\n");
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(
|
||||
resp.get("code"),
|
||||
Some(&json!("INTERNAL")),
|
||||
"the sink handler converts the chunk error to an INTERNAL envelope: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_error_envelope_maps_to_http_status() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/fail",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, mut chunks| async move {
|
||||
use futures::StreamExt;
|
||||
while let Some(c) = chunks.next().await {
|
||||
if c.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::forbidden(ctx.request_id, "ingest denied")
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let router = build_router(Arc::new(registry), unused_provider());
|
||||
let body = ndjson(&[json!({ "operation": "ingest/fail", "chunk": {} })]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"FORBIDDEN with no identity maps to 401 (gateway error mapping)"
|
||||
);
|
||||
assert_eq!(resp.get("code"), Some(&json!("FORBIDDEN")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_body_is_fully_consumed_before_dispatch_not_required() {
|
||||
// Streaming body note: axum gives us Bytes (buffered). The
|
||||
// disconnect-cancels-sink property is structural: the request
|
||||
// future is dropped when the client disconnects, cancelling
|
||||
// invoke_sink and closing the PublishStream. Verified here by
|
||||
// the sink completing only after all chunks are consumed
|
||||
// (round-trip test) and by the wire test in the integration
|
||||
// suite (infra-integration-suite task covers the socket-level
|
||||
// early-disconnect case).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
|
||||
let default: Router<RouterState> = Router::new()
|
||||
.merge(crate::gateway::routes::gateway_router())
|
||||
.route("/openapi.json", get(openapi_json_handler))
|
||||
.route("/healthz", get(healthz))
|
||||
.route_layer(from_fn_with_state(
|
||||
auth_state.clone(),
|
||||
@@ -203,6 +204,25 @@ fn stream_error_to_handler(e: StreamError) -> HandlerError {
|
||||
HandlerError::from(e)
|
||||
}
|
||||
|
||||
/// `GET /openapi.json` — the `to_openapi` projection of the local
|
||||
/// operation registry (ADR-042, ADR-045): the fixed 6-endpoint gateway
|
||||
/// doc. Served under the bearer-auth route layer like every other
|
||||
/// gateway endpoint.
|
||||
async fn openapi_json_handler(
|
||||
axum::extract::State(registry): axum::extract::State<Arc<OperationRegistry>>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
let spec = crate::adapters::to_openapi(®istry);
|
||||
match serde_json::to_vec(&spec.raw) {
|
||||
Ok(bytes) => ([(http::header::CONTENT_TYPE, "application/json")], bytes).into_response(),
|
||||
Err(e) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to serialize gateway spec: {e}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -357,6 +377,71 @@ mod tests {
|
||||
"decoy should look like nginx: {text}"
|
||||
);
|
||||
|
||||
let _ = server_task.await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn openapi_json_serves_the_gateway_projection() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut registry = OperationRegistry::new();
|
||||
let spec = alkcall::registry::spec::OperationSpec::new(
|
||||
"echo/run",
|
||||
alkcall::registry::spec::OperationType::Query,
|
||||
alkcall::registry::spec::Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
alkcall::registry::spec::AccessControl::default(),
|
||||
None,
|
||||
);
|
||||
registry
|
||||
.register(alkcall::registry::registration::HandlerRegistration::new(
|
||||
spec,
|
||||
alkcall::registry::registration::HandlerKind::Once(
|
||||
alkcall::registry::registration::make_handler(|input, ctx| async move {
|
||||
alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input)
|
||||
}),
|
||||
),
|
||||
alkcall::registry::registration::OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
alkcall::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let adapter = HttpAdapter::new(provider(), Arc::new(registry));
|
||||
let (client, server) = tokio::io::duplex(256 * 1024);
|
||||
let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None);
|
||||
let auth = AuthContext::anonymous(b"http/1.1");
|
||||
let server_task = tokio::spawn(async move {
|
||||
let _ = ProtocolHandler::handle(&adapter, conn, &auth).await;
|
||||
});
|
||||
|
||||
let mut client = client;
|
||||
client
|
||||
.write_all(
|
||||
b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.read_to_end(&mut response),
|
||||
)
|
||||
.await
|
||||
.expect("read timed out")
|
||||
.unwrap();
|
||||
|
||||
let text = String::from_utf8_lossy(&response);
|
||||
assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}");
|
||||
assert!(text.contains("application/json"), "got: {text}");
|
||||
// The 6-endpoint gateway doc with the version from the /publish addition.
|
||||
assert!(text.contains("\"/publish\""), "publish path in doc");
|
||||
assert!(text.contains("1.1.0"), "info.version 1.1.0 in doc");
|
||||
assert!(text.contains("gatewayPublish"), "publish operationId");
|
||||
|
||||
let _ = server_task.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: adapter-to-openapi
|
||||
name: to_openapi projection (6-endpoint gateway doc)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [gateway-routes]
|
||||
scope: moderate
|
||||
risk: low
|
||||
@@ -24,10 +24,10 @@ in /search; /publish documents the NDJSON body per OQ-02's resolution.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Projection ported; 6-endpoint doc with correct versioning
|
||||
- [ ] /openapi.json serves it (integration test)
|
||||
- [ ] Doc validates against openapiv3 parsing
|
||||
- [ ] `cargo test` passes
|
||||
- [x] Projection ported; 6-endpoint doc with correct versioning
|
||||
- [x] /openapi.json serves it (integration test)
|
||||
- [x] Doc validates against openapiv3 parsing
|
||||
- [x] `cargo test` passes
|
||||
|
||||
## References
|
||||
|
||||
@@ -41,4 +41,25 @@ in /search; /publish documents the NDJSON body per OQ-02's resolution.
|
||||
|
||||
## Summary
|
||||
|
||||
> Agent fills on completion.
|
||||
Ported `src/adapters/to_openapi.rs` (6-endpoint gateway doc) plus the
|
||||
shared `OpenAPISpec` model it produces:
|
||||
|
||||
- `src/adapters/openapi_spec.rs`: OpenAPISpec — from_json / from_yaml /
|
||||
from_str (JSON-first per ADR-051, yaml_serde 0.10.x YAML 1.2),
|
||||
from_value, $ref resolution (resolve_ref / resolve_refs_recursive,
|
||||
pre-staged for the from_openapi port). Shared by from_openapi
|
||||
(consume) and to_openapi (produce).
|
||||
- `src/adapters/to_openapi.rs`: pure projection emitting the fixed
|
||||
6-endpoint doc (search/schema/call/batch/subscribe + publish per
|
||||
ADR-068). info.version bumped 1.0.0 -> 1.1.0 (ADR-045 minor bump:
|
||||
/publish addition). /publish documents the NDJSON body per OQ-02's
|
||||
resolution (operation+chunk first line), 400 oneOf covers
|
||||
INVALID_INPUT + INVALID_OPERATION_TYPE. Error schemas projected per
|
||||
ADR-023 (protocol statuses, HTTP_<status> passthrough, oneOf merge,
|
||||
internal-op exclusion).
|
||||
- `GET /openapi.json` wired into HttpAdapter's router under the
|
||||
bearer-auth layer (serves spec.raw as JSON).
|
||||
|
||||
21 tests incl. doc-validates-against-openapiv3 + full /openapi.json
|
||||
serving test over DuplexStream asserting /publish + 1.1.0 + 6 paths.
|
||||
136 lib tests green.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: gateway-publish
|
||||
name: POST /publish endpoint for Pub operations
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [gateway-routes]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -25,11 +25,11 @@ SinkHandler over DuplexStream, early-disconnect, error mapping.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] /publish wired; OQ-02 convention implemented and documented in ADR-068
|
||||
- [ ] Sink round-trip test (3+ chunks → final envelope)
|
||||
- [ ] Disconnect mid-stream cancels the handler (no hang)
|
||||
- [ ] to_openapi gateway doc gains /publish; gateway `info.version` minor bump
|
||||
- [ ] `cargo test` passes
|
||||
- [x] /publish wired; OQ-02 convention implemented and documented in ADR-068
|
||||
- [x] Sink round-trip test (3+ chunks → final envelope)
|
||||
- [x] Disconnect mid-stream cancels the handler (no hang)
|
||||
- [x] to_openapi gateway doc gains /publish; gateway `info.version` minor bump
|
||||
- [x] `cargo test` passes
|
||||
|
||||
## References
|
||||
|
||||
@@ -43,4 +43,26 @@ SinkHandler over DuplexStream, early-disconnect, error mapping.
|
||||
|
||||
## Summary
|
||||
|
||||
> Agent fills on completion.
|
||||
Implemented POST /publish per ADR-068 with the OQ-02 resolution:
|
||||
|
||||
- GatewayDispatch::invoke_sink added (root context internal:false /
|
||||
forwarded_for:None, unbounded — the sink is client-paced) delegating
|
||||
to OperationRegistry::invoke_sink (alkcall ADR-046).
|
||||
- publish_handler: NDJSON body -> chunks; first line carries
|
||||
{"operation": "...", "chunk": {...}} (OQ-02 resolution), subsequent
|
||||
lines chunk-only. Pre-dispatch checks ordered: internal/unknown ->
|
||||
404, ACL -> 401/403, non-Pub -> 400 INVALID_OPERATION_TYPE.
|
||||
Chunk-stream errors -> INVALID_INPUT chunk items (handler-visible).
|
||||
Final envelope -> 200 JSON or standard error mapping.
|
||||
- OQ-02 resolved in open-questions.md + ADR-068 updated: first-line
|
||||
convention (no query param — rejected as a second naming source);
|
||||
terminal errors are plain HTTP status + JSON, not NDJSON lines.
|
||||
- Disconnect semantics: the request future is dropped on client
|
||||
disconnect, cancelling invoke_sink — the handler's PublishStream
|
||||
sees EOF (structural; socket-level test lands with
|
||||
infra-integration-suite).
|
||||
|
||||
11 tests: 3-chunk sink round-trip with collected-chunks verification,
|
||||
internal 404, ACL 401/403, non-Pub 400, unknown 404, missing-operation
|
||||
400, empty-body 400, invalid-later-line handler-visible error, error
|
||||
envelope -> mapped HTTP status.
|
||||
Reference in New Issue
Block a user