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
|
- Auth: `Authorization: Bearer <token>` — same as every gateway
|
||||||
endpoint ([ADR-004](004-auth-as-shared-core.md)).
|
endpoint ([ADR-004](004-auth-as-shared-core.md)).
|
||||||
- The target operation is named the same way as `/call` — the body's
|
- The target operation is named the same way as `/call` — the body's
|
||||||
first line (or a `?operation=` query parameter) carries
|
first line carries `{ "operation": "/{service}/{op}", "chunk": {...}`
|
||||||
`{ "operation": "/{service}/{op}", "chunk": {...} }` for the first
|
for the first chunk, with subsequent lines carrying `chunk` values
|
||||||
chunk, with subsequent lines carrying `chunk` values only; see OQ-02
|
only. (OQ-02 resolved: first-line convention, no query parameter, no
|
||||||
for the exact first-line convention before the gateway-spec version
|
header; a `?operation=` parameter was rejected because it duplicates
|
||||||
bumps.
|
the first-line field and complicates curl one-liners for no gain.)
|
||||||
|
|
||||||
### Dispatch
|
### Dispatch
|
||||||
|
|
||||||
@@ -114,8 +114,10 @@ the doc does not preload operations.
|
|||||||
indistinguishable from a network error to the server side (the
|
indistinguishable from a network error to the server side (the
|
||||||
handler sees EOF either way). Callers needing explicit failure
|
handler sees EOF either way). Callers needing explicit failure
|
||||||
semantics use the call protocol (WS channel 0).
|
semantics use the call protocol (WS channel 0).
|
||||||
- OQ-02 (first-line operation-naming convention) must settle before
|
- ~~OQ-02 (first-line operation-naming convention) must settle before
|
||||||
the `/openapi.json` version bumps.
|
the `/openapi.json` version bumps~~ — settled: first-line
|
||||||
|
`{operation, chunk}` convention; terminal errors are plain HTTP
|
||||||
|
status + JSON body (not an NDJSON line).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -40,15 +40,17 @@ with their resolutions; new alkhttp OQs start at OQ-01.
|
|||||||
### OQ-02: `/publish` body framing details
|
### OQ-02: `/publish` body framing details
|
||||||
|
|
||||||
- **Origin**: [ADR-068](decisions/068-gateway-publish-endpoint.md)
|
- **Origin**: [ADR-068](decisions/068-gateway-publish-endpoint.md)
|
||||||
- **Status**: open
|
- **Status**: resolved (implementation: gateway-publish task, 2026-08-28)
|
||||||
- **Priority**: medium
|
- **Priority**: medium
|
||||||
- **Resolution**: (pending)
|
- **Resolution**: First line of the NDJSON body carries
|
||||||
- **Question**: The exact first-line convention for naming the target
|
`{ "operation": "/{service}/{op}", "chunk": {...} }`; subsequent
|
||||||
operation (first line `{operation, chunk}` vs `?operation=` query
|
lines are chunk values only. Terminal errors are plain HTTP status +
|
||||||
parameter vs required header), and where a terminal error envelope
|
JSON body (NOT an NDJSON line) — consistent with every other gateway
|
||||||
lives (final NDJSON line of a JSON error object vs plain HTTP status
|
endpoint's error surface. A `?operation=` query parameter was
|
||||||
with JSON body). Must settle before the gateway contract's
|
considered and rejected: it duplicates the first-line field and adds
|
||||||
`info.version` bumps ([ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md)).
|
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
|
### OQ-03: `from_wss` reconnection semantics
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
|
|
||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod from_jsonschema;
|
pub mod from_jsonschema;
|
||||||
|
pub mod openapi_spec;
|
||||||
|
pub mod to_openapi;
|
||||||
|
|
||||||
pub use forward::{HttpAuthScheme, HttpServiceConfig};
|
pub use forward::{HttpAuthScheme, HttpServiceConfig};
|
||||||
pub use from_jsonschema::FromJsonSchema;
|
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)
|
.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(
|
fn build_root_context(
|
||||||
&self,
|
&self,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
|
|||||||
+412
-1
@@ -25,7 +25,7 @@ use serde_json::{json, Value};
|
|||||||
use alkcall::core::auth::{Identity, IdentityProvider};
|
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
use alkcall::registry::registration::OperationRegistry;
|
use alkcall::registry::registration::OperationRegistry;
|
||||||
use alkcall::registry::spec::{AccessResult, Visibility};
|
use alkcall::registry::spec::{AccessResult, OperationType, Visibility};
|
||||||
|
|
||||||
use super::dispatch::GatewayDispatch;
|
use super::dispatch::GatewayDispatch;
|
||||||
use super::error::{call_error_to_http_response, call_error_to_http_status_with_identity};
|
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("/call", post(call_handler))
|
||||||
.route("/batch", post(batch_handler))
|
.route("/batch", post(batch_handler))
|
||||||
.route("/subscribe", post(subscribe_handler))
|
.route("/subscribe", post(subscribe_handler))
|
||||||
|
.route("/publish", post(publish_handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -173,6 +174,128 @@ pub(crate) async fn subscribe_handler(
|
|||||||
|
|
||||||
pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
|
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(
|
fn subscribe_stream_from_envelope_stream(
|
||||||
stream: BoxStream<'static, ResponseEnvelope>,
|
stream: BoxStream<'static, ResponseEnvelope>,
|
||||||
) -> SubscribeStream {
|
) -> SubscribeStream {
|
||||||
@@ -1169,4 +1292,292 @@ mod tests {
|
|||||||
let resp = router.oneshot(req).await.unwrap();
|
let resp = router.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
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()
|
let default: Router<RouterState> = Router::new()
|
||||||
.merge(crate::gateway::routes::gateway_router())
|
.merge(crate::gateway::routes::gateway_router())
|
||||||
|
.route("/openapi.json", get(openapi_json_handler))
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route_layer(from_fn_with_state(
|
.route_layer(from_fn_with_state(
|
||||||
auth_state.clone(),
|
auth_state.clone(),
|
||||||
@@ -203,6 +204,25 @@ fn stream_error_to_handler(e: StreamError) -> HandlerError {
|
|||||||
HandlerError::from(e)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -357,6 +377,71 @@ mod tests {
|
|||||||
"decoy should look like nginx: {text}"
|
"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;
|
let _ = server_task.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: adapter-to-openapi
|
id: adapter-to-openapi
|
||||||
name: to_openapi projection (6-endpoint gateway doc)
|
name: to_openapi projection (6-endpoint gateway doc)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [gateway-routes]
|
depends_on: [gateway-routes]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: low
|
risk: low
|
||||||
@@ -24,10 +24,10 @@ in /search; /publish documents the NDJSON body per OQ-02's resolution.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Projection ported; 6-endpoint doc with correct versioning
|
- [x] Projection ported; 6-endpoint doc with correct versioning
|
||||||
- [ ] /openapi.json serves it (integration test)
|
- [x] /openapi.json serves it (integration test)
|
||||||
- [ ] Doc validates against openapiv3 parsing
|
- [x] Doc validates against openapiv3 parsing
|
||||||
- [ ] `cargo test` passes
|
- [x] `cargo test` passes
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -41,4 +41,25 @@ in /search; /publish documents the NDJSON body per OQ-02's resolution.
|
|||||||
|
|
||||||
## Summary
|
## 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
|
id: gateway-publish
|
||||||
name: POST /publish endpoint for Pub operations
|
name: POST /publish endpoint for Pub operations
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [gateway-routes]
|
depends_on: [gateway-routes]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -25,11 +25,11 @@ SinkHandler over DuplexStream, early-disconnect, error mapping.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] /publish wired; OQ-02 convention implemented and documented in ADR-068
|
- [x] /publish wired; OQ-02 convention implemented and documented in ADR-068
|
||||||
- [ ] Sink round-trip test (3+ chunks → final envelope)
|
- [x] Sink round-trip test (3+ chunks → final envelope)
|
||||||
- [ ] Disconnect mid-stream cancels the handler (no hang)
|
- [x] Disconnect mid-stream cancels the handler (no hang)
|
||||||
- [ ] to_openapi gateway doc gains /publish; gateway `info.version` minor bump
|
- [x] to_openapi gateway doc gains /publish; gateway `info.version` minor bump
|
||||||
- [ ] `cargo test` passes
|
- [x] `cargo test` passes
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -43,4 +43,26 @@ SinkHandler over DuplexStream, early-disconnect, error mapping.
|
|||||||
|
|
||||||
## Summary
|
## 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