fix(gateway): publish validation + streaming + batch semantics (GW-01, GW-06, GW-08..GW-11, HY-13)
- GW-01: /publish validates every NDJSON chunk against the op's publish_schema (incl. the first-line chunk) via NdjsonChunkStream — terminal Err(INVALID_INPUT)/422 on violation, matching the wire dispatcher's per-chunk contract. Route-level fix; the alkcall spine was explored and rejected (wire validation is pump-side by design). - GW-06: the body is streamed, not buffered — Body::into_data_stream() -> newline-framed BufferedLines -> lazily parsed chunk stream. ADR-068 documents the streamed semantics and the 2 MiB per-line cap. - GW-08: /batch capped at 100 operations (INVALID_INPUT 400). - GW-09: internal-op batch entries now carry generated UUID request ids. - GW-10: first publish line missing `chunk` is rejected INVALID_INPUT. - GW-11: redundant /publish pre-checks removed; enforcement rides on invoke_sink via the shared dispatch spine. - HY-13: the vacuous stub test was replaced by a body-cut-short test. - Adjacent: INVALID_OPERATION_TYPE now maps 422 (with identity) / 401 (without) in error.rs — the route relies on the shared mapper since the pre-checks are gone (GW-03's finding; was a 500 fall-through). Verification: cargo test 211 passed; cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean.
This commit is contained in:
+28
-4
@@ -1,10 +1,10 @@
|
||||
//! CallError → HTTP status/response mapping ([ADR-023]).
|
||||
//!
|
||||
//! Protocol-level vs operation-level code distinction: protocol codes
|
||||
//! (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `TIMEOUT`, `INTERNAL`)
|
||||
//! map to fixed statuses; operation-level codes imported from external
|
||||
//! HTTP APIs are prefixed `HTTP_<status>` and map to their declared
|
||||
//! status.
|
||||
//! (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`,
|
||||
//! `TIMEOUT`, `INTERNAL`) map to fixed statuses; operation-level codes
|
||||
//! imported from external HTTP APIs are prefixed `HTTP_<status>` and map
|
||||
//! to their declared status.
|
||||
//!
|
||||
//! [ADR-023]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
||||
|
||||
@@ -17,6 +17,7 @@ use serde_json::Value;
|
||||
const PROTOCOL_CODE_NOT_FOUND: &str = "NOT_FOUND";
|
||||
const PROTOCOL_CODE_FORBIDDEN: &str = "FORBIDDEN";
|
||||
const PROTOCOL_CODE_INVALID_INPUT: &str = "INVALID_INPUT";
|
||||
const PROTOCOL_CODE_INVALID_OPERATION_TYPE: &str = "INVALID_OPERATION_TYPE";
|
||||
const PROTOCOL_CODE_TIMEOUT: &str = "TIMEOUT";
|
||||
const PROTOCOL_CODE_INTERNAL: &str = "INTERNAL";
|
||||
|
||||
@@ -49,6 +50,13 @@ pub fn call_error_to_http_status_with_identity(
|
||||
}
|
||||
}
|
||||
PROTOCOL_CODE_INVALID_INPUT => STATUS_UNPROCESSABLE,
|
||||
PROTOCOL_CODE_INVALID_OPERATION_TYPE => {
|
||||
if identity.is_some() {
|
||||
STATUS_UNPROCESSABLE
|
||||
} else {
|
||||
STATUS_UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
PROTOCOL_CODE_TIMEOUT => STATUS_TIMEOUT,
|
||||
PROTOCOL_CODE_INTERNAL => STATUS_INTERNAL,
|
||||
code if code.starts_with(HTTP_PREFIX) => code[HTTP_PREFIX.len()..]
|
||||
@@ -123,6 +131,22 @@ mod tests {
|
||||
assert_eq!(call_error_to_http_status(&error), 422);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operation_type_with_identity_maps_to_422() {
|
||||
let error = CallError::invalid_operation_type("not a Pub op");
|
||||
let id = identity();
|
||||
assert_eq!(
|
||||
call_error_to_http_status_with_identity(&error, Some(&id)),
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operation_type_without_identity_maps_to_401() {
|
||||
let error = CallError::invalid_operation_type("not a Pub op");
|
||||
assert_eq!(call_error_to_http_status_with_identity(&error, None), 401);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504() {
|
||||
let error = CallError::timeout("timed out");
|
||||
|
||||
+476
-136
@@ -1,16 +1,12 @@
|
||||
//! The 5 fixed gateway endpoints (`/search`, `/schema`, `/call`,
|
||||
//! `/batch`, `/subscribe`) — the sole HTTP invoke path (ADR-042,
|
||||
//! ADR-047).
|
||||
//!
|
||||
//! Each endpoint delegates to `GatewayDispatch::invoke()` (the shared
|
||||
//! dispatch spine); auth is the shared `bearer_auth_middleware`; error
|
||||
//! mapping is `gateway::error`. There is no per-operation
|
||||
//! `POST /{service}/{op}` direct-call surface (ADR-047). `/publish`
|
||||
//! (ADR-068) is a separate module.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use alkcall::registry::spec::{AccessResult, Visibility};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{FromRef, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::Event;
|
||||
@@ -19,14 +15,10 @@ use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use futures::stream::{self, BoxStream};
|
||||
use futures::StreamExt;
|
||||
use jsonschema::Validator;
|
||||
use serde::Deserialize;
|
||||
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, OperationType, Visibility};
|
||||
|
||||
use super::dispatch::GatewayDispatch;
|
||||
use super::error::{call_error_to_http_response, call_error_to_http_status_with_identity};
|
||||
use crate::server::auth::ResolvedIdentity;
|
||||
@@ -34,6 +26,10 @@ use crate::server::state::RouterState;
|
||||
|
||||
const SERVICES_LIST: &str = "services/list";
|
||||
const SERVICES_SCHEMA: &str = "services/schema";
|
||||
const MAX_BATCH_OPERATIONS: usize = 100;
|
||||
const MAX_PUBLISH_LINE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct GatewayState {
|
||||
@@ -141,6 +137,18 @@ pub(crate) async fn batch_handler(
|
||||
ResolvedIdentity(identity): ResolvedIdentity,
|
||||
Json(requests): Json<Vec<CallRequest>>,
|
||||
) -> Response {
|
||||
if requests.len() > MAX_BATCH_OPERATIONS {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": format!(
|
||||
"batch exceeds the maximum of {MAX_BATCH_OPERATIONS} operations"
|
||||
),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let dispatch = state.dispatch();
|
||||
let mut results: Vec<Value> = Vec::with_capacity(requests.len());
|
||||
for request in requests {
|
||||
@@ -178,122 +186,233 @@ pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
|
||||
/// 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.
|
||||
/// status + JSON body (not an NDJSON line). The body is streamed (never
|
||||
/// fully buffered): the first non-blank line names the operation and
|
||||
/// carries the first chunk, then the remainder streams into the sink —
|
||||
/// a client disconnect drops the body stream and 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,
|
||||
body: axum::body::Body,
|
||||
) -> 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()
|
||||
}
|
||||
let mut header_lines = BufferedLines::new(Box::pin(body.into_data_stream()));
|
||||
let (operation, first_chunk, validator): (String, Value, Option<jsonschema::Validator>) = {
|
||||
let line = match header_lines.next_line().await {
|
||||
Ok(Some(line)) => line,
|
||||
Ok(None) => {
|
||||
return invalid_input_response("empty publish body: expected NDJSON chunks")
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": format!("first publish line is not valid JSON: {e}"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => return invalid_input_response(e.message()),
|
||||
};
|
||||
let value = match serde_json::from_slice::<Value>(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return invalid_input_response(&format!(
|
||||
"first publish line is not valid JSON: {e}"
|
||||
))
|
||||
}
|
||||
};
|
||||
let op = value
|
||||
.get("operation")
|
||||
.and_then(|o| o.as_str())
|
||||
.map(str::to_string);
|
||||
let Some(op) = op else {
|
||||
return missing_header_field_response();
|
||||
};
|
||||
let Some(chunk) = value.get("chunk") else {
|
||||
return missing_header_field_response();
|
||||
};
|
||||
let validator = state
|
||||
.registry
|
||||
.registration(op.strip_prefix('/').unwrap_or(&op))
|
||||
.and_then(|reg| reg.spec.publish_schema.clone())
|
||||
.and_then(|schema| match jsonschema::options().build(&schema) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
operation = %op,
|
||||
error = %e,
|
||||
"publish_schema failed to compile; chunks will not be validated"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
(op, chunk.clone(), validator)
|
||||
};
|
||||
|
||||
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 chunks = NdjsonChunkStream::new(header_lines, validator, Some(first_chunk));
|
||||
let dispatch = state.dispatch();
|
||||
let envelope = dispatch
|
||||
.invoke_sink(
|
||||
identity.clone(),
|
||||
&operation,
|
||||
Value::Null,
|
||||
Box::pin(futures::stream::iter(chunks)),
|
||||
)
|
||||
.invoke_sink(identity.clone(), &operation, Value::Null, Box::pin(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 missing_header_field_response() -> Response {
|
||||
invalid_input_response("first publish line must carry {\"operation\": ..., \"chunk\": ...}")
|
||||
}
|
||||
|
||||
fn invalid_input_response(message: &str) -> Response {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "code": "INVALID_INPUT", "message": message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// A newline-framed reader over the request body's byte stream. Blank
|
||||
/// lines are skipped; a trailing unterminated line is yielded as a
|
||||
/// final line. Lines are capped at `MAX_PUBLISH_LINE_BYTES` (in
|
||||
/// addition to axum's own 2 MiB default body limit); a byte-level read
|
||||
/// failure surfaces as a terminal error.
|
||||
struct BufferedLines {
|
||||
bytes: ByteStream,
|
||||
buffer: Vec<u8>,
|
||||
pending: VecDeque<Vec<u8>>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl BufferedLines {
|
||||
fn new(bytes: ByteStream) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
buffer: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_line(&mut self) -> Result<Option<Vec<u8>>, LineError> {
|
||||
loop {
|
||||
if let Some(line) = self.pending.pop_front() {
|
||||
if line.iter().all(|b| b.is_ascii_whitespace()) {
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if self.done {
|
||||
if self.buffer.iter().all(|b| b.is_ascii_whitespace()) {
|
||||
self.buffer.clear();
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some(std::mem::take(&mut self.buffer)));
|
||||
}
|
||||
match self.bytes.next().await {
|
||||
Some(Ok(bytes)) => self.buffer.extend_from_slice(&bytes),
|
||||
Some(Err(_)) => {
|
||||
self.done = true;
|
||||
return Err(LineError::Read);
|
||||
}
|
||||
None => {
|
||||
self.done = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
while let Some(pos) = self.buffer.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = self.buffer.drain(..=pos).collect();
|
||||
let line = &line[..line.len() - 1];
|
||||
if line.len() > MAX_PUBLISH_LINE_BYTES {
|
||||
self.done = true;
|
||||
self.buffer.clear();
|
||||
self.pending.clear();
|
||||
return Err(LineError::LineCap);
|
||||
}
|
||||
self.pending.push_back(line.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
enum LineError {
|
||||
Read,
|
||||
LineCap,
|
||||
}
|
||||
|
||||
impl LineError {
|
||||
fn message(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "publish body read failed",
|
||||
Self::LineCap => "publish line exceeds the per-line cap",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The initiator-side chunk stream fed to `invoke_sink` (GW-01, GW-06):
|
||||
/// NDJSON lines parsed lazily straight from the request body, each
|
||||
/// validated against the op's compiled `publish_schema` — matching the
|
||||
/// wire dispatcher's per-chunk validation (alkcall ADR-046). Malformed
|
||||
/// JSON, a schema violation, or a body-read failure yields a terminal
|
||||
/// `Err(INVALID_INPUT)` item — the same shape an initiator-side
|
||||
/// `call.error` produces on the wire.
|
||||
struct NdjsonChunkStream {
|
||||
lines: std::pin::Pin<Box<BufferedLines>>,
|
||||
validator: Option<jsonschema::Validator>,
|
||||
pending_first: Option<Value>,
|
||||
}
|
||||
|
||||
impl NdjsonChunkStream {
|
||||
fn new(
|
||||
lines: BufferedLines,
|
||||
validator: Option<jsonschema::Validator>,
|
||||
pending_first: Option<Value>,
|
||||
) -> Self {
|
||||
Self {
|
||||
lines: Box::pin(lines),
|
||||
validator,
|
||||
pending_first,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_chunk(
|
||||
&mut self,
|
||||
value: Value,
|
||||
) -> std::task::Poll<Option<Result<Value, CallError>>> {
|
||||
if let Some(validator) = &self.validator {
|
||||
if !Validator::is_valid(validator, &value) {
|
||||
return std::task::Poll::Ready(Some(Err(CallError::invalid_input(
|
||||
"published chunk failed publish_schema validation",
|
||||
)
|
||||
.with_details(json!({ "chunk": value })))));
|
||||
}
|
||||
}
|
||||
std::task::Poll::Ready(Some(Ok(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl futures::Stream for NdjsonChunkStream {
|
||||
type Item = Result<Value, CallError>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
if self.pending_first.is_some() {
|
||||
let first = self.pending_first.take().unwrap_or(Value::Null);
|
||||
return self.validate_chunk(first);
|
||||
}
|
||||
let line = {
|
||||
let mut next = Box::pin(self.lines.next_line());
|
||||
match std::future::Future::poll(next.as_mut(), cx) {
|
||||
std::task::Poll::Ready(Ok(Some(line))) => line,
|
||||
std::task::Poll::Ready(Ok(None)) => return std::task::Poll::Ready(None),
|
||||
std::task::Poll::Ready(Err(e)) => {
|
||||
return std::task::Poll::Ready(Some(Err(CallError::invalid_input(e.message()))))
|
||||
}
|
||||
std::task::Poll::Pending => return std::task::Poll::Pending,
|
||||
}
|
||||
};
|
||||
let value = match serde_json::from_slice::<Value>(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return std::task::Poll::Ready(Some(Err(CallError::invalid_input(format!(
|
||||
"publish line is not valid JSON: {e}"
|
||||
)))))
|
||||
}
|
||||
};
|
||||
self.validate_chunk(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_stream_from_envelope_stream(
|
||||
@@ -358,7 +477,7 @@ fn envelope_to_error_json(request_id: &str, error: &CallError) -> Value {
|
||||
fn not_found_envelope_json(operation: &str) -> Value {
|
||||
let error = CallError::not_found(operation);
|
||||
json!({
|
||||
"request_id": Value::Null,
|
||||
"request_id": uuid::Uuid::new_v4().to_string(),
|
||||
"result": "error",
|
||||
"error": serde_json::to_value(&error).unwrap_or(Value::Null),
|
||||
})
|
||||
@@ -967,6 +1086,40 @@ mod tests {
|
||||
results[1].get("error").and_then(|e| e.get("code")),
|
||||
Some(&json!("NOT_FOUND"))
|
||||
);
|
||||
assert!(
|
||||
results[1]
|
||||
.get("request_id")
|
||||
.map(|id| !id.is_null())
|
||||
.unwrap_or(false),
|
||||
"internal-op entries must carry a generated request_id, not null"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn batch_exceeding_operation_cap_returns_400_invalid_input() {
|
||||
let router = build_router(registry_with_echo(), unused_provider());
|
||||
let requests: Vec<Value> = (0..MAX_BATCH_OPERATIONS + 1)
|
||||
.map(|i| json!({ "operation": "echo/run", "input": { "n": i } }))
|
||||
.collect();
|
||||
let req = json_request("POST", "/batch", json!(requests));
|
||||
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 batch_at_cap_dispatches_all_entries() {
|
||||
let router = build_router(registry_with_echo(), unused_provider());
|
||||
let requests: Vec<Value> = (0..MAX_BATCH_OPERATIONS)
|
||||
.map(|i| json!({ "operation": "echo/run", "input": { "n": i } }))
|
||||
.collect();
|
||||
let req = json_request("POST", "/batch", json!(requests));
|
||||
let (status, body) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let results = body
|
||||
.get("results")
|
||||
.and_then(|r| r.as_array())
|
||||
.expect("results array");
|
||||
assert_eq!(results.len(), MAX_BATCH_OPERATIONS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1316,12 +1469,7 @@ mod tests {
|
||||
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:?}")),
|
||||
)
|
||||
}
|
||||
Err(e) => return ResponseEnvelope::error(ctx.request_id, e),
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1349,6 +1497,46 @@ mod tests {
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/typed",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_publish_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "n": { "type": "integer" } },
|
||||
"required": ["n"],
|
||||
"additionalProperties": false
|
||||
})),
|
||||
HandlerKind::Sink(make_sink_handler(
|
||||
|_unused_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, e),
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
json!({ "count": collected.len(), "chunks": collected }),
|
||||
)
|
||||
},
|
||||
)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
@@ -1474,12 +1662,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_non_pub_op_returns_400_invalid_operation_type() {
|
||||
async fn publish_non_pub_op_unauthenticated_maps_invalid_operation_type_to_401() {
|
||||
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!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body.get("code"), Some(&json!("INVALID_OPERATION_TYPE")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_non_pub_op_returns_422_invalid_operation_type() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
||||
StaticIdentityProvider::new()
|
||||
.with_token("user-tok", identity_with_scopes("user", &["user"])),
|
||||
);
|
||||
let router = build_router(publish_registry(), provider);
|
||||
let body = ndjson(&[json!({ "operation": "echo/run", "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::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(body.get("code"), Some(&json!("INVALID_OPERATION_TYPE")));
|
||||
}
|
||||
|
||||
@@ -1512,6 +1721,16 @@ mod tests {
|
||||
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_first_line_missing_chunk_returns_400_invalid_input() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[json!({ "operation": "ingest/push" })]);
|
||||
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_invalid_later_line_yields_handler_chunk_error() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
@@ -1519,11 +1738,11 @@ mod tests {
|
||||
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!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
resp.get("code"),
|
||||
Some(&json!("INTERNAL")),
|
||||
"the sink handler converts the chunk error to an INTERNAL envelope: {resp}"
|
||||
Some(&json!("INVALID_INPUT")),
|
||||
"the malformed chunk line terminates the stream as INVALID_INPUT: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1570,14 +1789,135 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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).
|
||||
async fn publish_schema_registered_op_rejects_invalid_chunk() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[
|
||||
json!({ "operation": "ingest/typed", "chunk": { "n": 1 } }),
|
||||
json!({ "n": "not-an-integer" }),
|
||||
json!({ "n": 3 }),
|
||||
]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
resp.get("code"),
|
||||
Some(&json!("INVALID_INPUT")),
|
||||
"the chunk stream terminates with the schema violation: {resp}"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.get("message"),
|
||||
Some(&json!("published chunk failed publish_schema validation"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_schema_registered_op_accepts_valid_chunks() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[
|
||||
json!({ "operation": "ingest/typed", "chunk": { "n": 1 } }),
|
||||
json!({ "n": 2 }),
|
||||
]);
|
||||
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")));
|
||||
assert_eq!(resp["output"]["count"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_op_without_schema_accepts_arbitrary_chunks() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[
|
||||
json!({ "operation": "ingest/push", "chunk": { "anything": true } }),
|
||||
json!({ "n": null }),
|
||||
]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(resp["output"]["count"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_first_chunk_validated_against_publish_schema() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let body = ndjson(&[json!({ "operation": "ingest/typed", "chunk": { "wrong": 1 } })]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(resp.get("code"), Some(&json!("INVALID_INPUT")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_line_exceeding_cap_yields_invalid_input_chunk_error() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/big",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
),
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, mut chunks| async move {
|
||||
use futures::StreamExt;
|
||||
let mut last_error = None;
|
||||
while let Some(chunk) = chunks.next().await {
|
||||
if let Err(e) = chunk {
|
||||
last_error = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let error = last_error.expect("an oversized line must produce an error item");
|
||||
ResponseEnvelope::error(ctx.request_id, error)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let router = build_router(Arc::new(registry), unused_provider());
|
||||
let mut body = ndjson(&[json!({ "operation": "ingest/big", "chunk": { "n": 1 } })]);
|
||||
let oversized = vec![b'a'; MAX_PUBLISH_LINE_BYTES + 1];
|
||||
body.extend_from_slice(&oversized);
|
||||
body.push(b'\n');
|
||||
let (status, resp) = send(router, raw_request("POST", "/publish", body)).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(resp.get("code"), Some(&json!("INVALID_INPUT")));
|
||||
assert!(
|
||||
resp.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|m| m.contains("publish line exceeds the per-line cap"))
|
||||
.unwrap_or(false),
|
||||
"expected the line-cap message, got: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_client_disconnect_before_dispatch_signals_error_item() {
|
||||
let router = build_router(publish_registry(), unused_provider());
|
||||
let first_line = serde_json::to_vec(&json!({
|
||||
"operation": "ingest/push",
|
||||
"chunk": { "n": 1 }
|
||||
}))
|
||||
.unwrap();
|
||||
let mut body = first_line.clone();
|
||||
body.push(b'\n');
|
||||
body.extend_from_slice(
|
||||
b"not-json
|
||||
",
|
||||
);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
resp.get("code"),
|
||||
Some(&json!("INVALID_INPUT")),
|
||||
"the prematurely terminated body reaches the handler as an INVALID_INPUT Err item: {resp}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user