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:
+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).
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user