use std::collections::VecDeque;
use std::convert::Infallible;
use std::sync::Arc;
use std::time::Duration;
use alkcall::core::auth::Identity;
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::header::{CACHE_CONTROL, VARY};
use axum::http::{HeaderValue, StatusCode};
use axum::response::sse::{Event, KeepAlive};
use axum::response::{IntoResponse, Json, Response, Sse};
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 super::error::call_error_to_http_response_with_identity;
use super::{GatewayDispatch, MAX_BATCH_OPERATIONS};
use crate::server::auth::ResolvedIdentity;
use crate::server::state::RouterState;
const SERVICES_LIST: &str = "services/list";
const SERVICES_SCHEMA: &str = "services/schema";
const MAX_PUBLISH_LINE_BYTES: usize = 2 * 1024 * 1024;
const GATEWAY_BODY_LIMIT: usize = MAX_PUBLISH_LINE_BYTES + 64 * 1024;
const GATEWAY_BODY_LIMIT_EXCEEDED: &str = "gateway request body limit exceeded";
const SSE_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15);
type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>>;
#[derive(Clone)]
pub(crate) struct GatewayState {
registry: Arc<OperationRegistry>,
}
impl GatewayState {
pub(crate) fn new(registry: Arc<OperationRegistry>) -> Self {
Self { registry }
}
fn dispatch(&self) -> GatewayDispatch {
GatewayDispatch::new(Arc::clone(&self.registry))
}
}
impl FromRef<RouterState> for GatewayState {
fn from_ref(state: &RouterState) -> Self {
GatewayState::new(Arc::clone(&state.registry))
}
}
pub(crate) fn gateway_router() -> Router<RouterState> {
Router::new()
.route("/search", get(search_handler))
.route("/schema", get(schema_handler))
.route("/call", post(call_handler))
.route("/batch", post(batch_handler))
.route("/subscribe", post(subscribe_handler))
.route("/publish", post(publish_handler))
.layer(axum::middleware::from_fn(gateway_body_limit))
}
#[derive(Debug, Deserialize)]
pub struct CallRequest {
pub operation: String,
#[serde(default = "Value::default")]
pub input: Value,
}
#[derive(Debug, Deserialize)]
pub struct SchemaQuery {
pub name: String,
}
pub(crate) async fn call_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(request): Json<CallRequest>,
) -> Response {
if is_internal_op(&state.registry, &request.operation) {
return not_found_response(&request.operation);
}
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(identity.clone(), &request.operation, request.input)
.await;
envelope_to_response(envelope, identity.as_ref())
}
pub(crate) async fn search_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
) -> Response {
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(identity.clone(), SERVICES_LIST, json!({}))
.await;
discovery_get_response(envelope, identity.as_ref())
}
pub(crate) async fn schema_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Query(query): Query<SchemaQuery>,
) -> Response {
if is_internal_op(&state.registry, &query.name) {
return with_no_cache_headers(not_found_response(&query.name));
}
if let Some(forbidden) = access_check_for_op(&state.registry, &query.name, identity.as_ref()) {
return with_no_cache_headers(forbidden_response(forbidden, identity.as_ref()));
}
let dispatch = state.dispatch();
let envelope = dispatch
.invoke(
identity.clone(),
SERVICES_SCHEMA,
json!({ "name": query.name }),
)
.await;
discovery_get_response(envelope, identity.as_ref())
}
pub(crate) async fn batch_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(requests): Json<Vec<CallRequest>>,
) -> Response {
if requests.len() > MAX_BATCH_OPERATIONS {
return call_error_to_http_response_with_identity(
&CallError::invalid_input(format!(
"batch exceeds the maximum of {MAX_BATCH_OPERATIONS} operations"
)),
identity.as_ref(),
);
}
let dispatch = state.dispatch();
let mut results: Vec<Value> = Vec::with_capacity(requests.len());
for request in requests {
if is_internal_op(&state.registry, &request.operation) {
results.push(not_found_envelope_json(&request.operation));
continue;
}
let envelope = dispatch
.invoke(identity.clone(), &request.operation, request.input)
.await;
results.push(envelope_to_json(envelope));
}
Json(json!({ "results": results })).into_response()
}
pub(crate) async fn subscribe_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
Json(request): Json<CallRequest>,
) -> Response {
let stream = if is_internal_op(&state.registry, &request.operation) {
subscribe_stream_internal_error(request.operation)
} else {
let dispatch = state.dispatch();
let envelope_stream =
dispatch.invoke_streaming(identity, &request.operation, request.input);
subscribe_stream_from_envelope_stream(envelope_stream)
};
Sse::new(stream)
.keep_alive(
KeepAlive::new()
.interval(SSE_KEEP_ALIVE_INTERVAL)
.event(keep_alive_event()),
)
.into_response()
}
pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
pub(crate) async fn publish_handler(
State(state): State<GatewayState>,
ResolvedIdentity(identity): ResolvedIdentity,
body: axum::body::Body,
) -> Response {
let mut header_lines = BufferedLines::new(Box::pin(body.into_data_stream()));
let (operation, first_chunk) = {
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 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();
};
(op, chunk.clone())
};
let chunks = NdjsonChunkStream::new(
header_lines,
Arc::clone(&state.registry),
operation.clone(),
Some(first_chunk),
);
let dispatch = state.dispatch();
let envelope = dispatch
.invoke_sink(identity.clone(), &operation, Value::Null, Box::pin(chunks))
.await;
envelope_to_response(envelope, identity.as_ref())
}
enum PublishSchemaState {
Unresolved {
registry: Arc<OperationRegistry>,
operation: String,
},
Unvalidated,
Validated(jsonschema::Validator),
}
impl PublishSchemaState {
fn resolve(&mut self) {
let Self::Unresolved {
registry,
operation,
} = self
else {
return;
};
let operation = std::mem::take(operation);
*self = match registry.publish_validator(&operation) {
Some(validator) => Self::Validated(validator),
None => Self::Unvalidated,
};
}
fn validate_chunk(
&mut self,
value: Value,
) -> std::task::Poll<Option<Result<Value, CallError>>> {
self.resolve();
match self {
Self::Unvalidated => std::task::Poll::Ready(Some(Ok(value))),
Self::Validated(validator) => {
if Validator::is_valid(validator, &value) {
std::task::Poll::Ready(Some(Ok(value)))
} else {
std::task::Poll::Ready(Some(Err(CallError::invalid_input(
"published chunk failed publish_schema validation",
)
.with_details(json!({ "chunk": value })))))
}
}
Self::Unresolved { .. } => unreachable!("resolve() runs before validation"),
}
}
}
fn missing_header_field_response() -> Response {
invalid_input_response("first publish line must carry {\"operation\": ..., \"chunk\": ...}")
}
fn invalid_input_response(message: &str) -> Response {
call_error_to_http_response_with_identity(&CallError::invalid_input(message), None)
}
async fn gateway_body_limit(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
let (parts, body) = req.into_parts();
if let Some(len) = parts
.headers
.get(axum::http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<usize>().ok())
{
if len > GATEWAY_BODY_LIMIT {
return gateway_body_limit_exceeded();
}
}
let exceeded = Arc::new(std::sync::atomic::AtomicBool::new(false));
let limited_req = axum::extract::Request::from_parts(
parts,
axum::body::Body::from_stream(LimitedBody {
inner: body.into_data_stream(),
remaining: GATEWAY_BODY_LIMIT,
exceeded: Arc::clone(&exceeded),
}),
);
let response = next.run(limited_req).await;
if exceeded.load(std::sync::atomic::Ordering::Relaxed) {
return gateway_body_limit_exceeded();
}
response
}
fn gateway_body_limit_exceeded() -> Response {
(
StatusCode::PAYLOAD_TOO_LARGE,
"gateway request body limit exceeded",
)
.into_response()
}
struct LimitedBody {
inner: axum::body::BodyDataStream,
remaining: usize,
exceeded: Arc<std::sync::atomic::AtomicBool>,
}
impl futures::Stream for LimitedBody {
type Item = Result<Bytes, std::io::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
let this = &mut *self;
match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
std::task::Poll::Ready(Some(Ok(data))) => {
let len = data.len();
if len > this.remaining {
this.remaining = 0;
this.exceeded
.store(true, std::sync::atomic::Ordering::Relaxed);
return std::task::Poll::Ready(Some(Err(std::io::Error::other(
GATEWAY_BODY_LIMIT_EXCEEDED,
))));
}
this.remaining -= len;
std::task::Poll::Ready(Some(Ok(data)))
}
std::task::Poll::Ready(Some(Err(_))) => std::task::Poll::Ready(Some(Err(
std::io::Error::other(GATEWAY_BODY_LIMIT_EXCEEDED),
))),
std::task::Poll::Pending => std::task::Poll::Pending,
std::task::Poll::Ready(None) => std::task::Poll::Ready(None),
}
}
}
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,
}
}
fn abort_on_cap(&mut self) -> LineError {
self.done = true;
self.buffer.clear();
self.pending.clear();
LineError::LineCap
}
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);
}
if self.buffer.len() > MAX_PUBLISH_LINE_BYTES {
return Err(self.abort_on_cap());
}
return Ok(Some(std::mem::take(&mut self.buffer)));
}
match self.bytes.next().await {
Some(Ok(bytes)) => {
self.buffer.extend_from_slice(&bytes);
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 {
return Err(self.abort_on_cap());
}
self.pending.push_back(line.to_vec());
}
if self.buffer.len() > MAX_PUBLISH_LINE_BYTES {
return Err(self.abort_on_cap());
}
}
Some(Err(_)) => {
self.done = true;
return Err(LineError::Read);
}
None => {
self.done = true;
continue;
}
}
}
}
}
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",
}
}
}
struct NdjsonChunkStream {
lines: std::pin::Pin<Box<BufferedLines>>,
schema_state: PublishSchemaState,
pending_first: Option<Value>,
done: bool,
}
impl NdjsonChunkStream {
fn new(
lines: BufferedLines,
registry: Arc<OperationRegistry>,
operation: String,
pending_first: Option<Value>,
) -> Self {
Self {
lines: Box::pin(lines),
schema_state: PublishSchemaState::Unresolved {
registry,
operation,
},
pending_first,
done: false,
}
}
fn poll_validated(
&mut self,
value: Value,
) -> std::task::Poll<Option<Result<Value, CallError>>> {
match self.schema_state.validate_chunk(value) {
std::task::Poll::Ready(Some(Err(e))) => {
self.done = true;
std::task::Poll::Ready(Some(Err(e)))
}
other => other,
}
}
}
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.done {
return std::task::Poll::Ready(None);
}
if self.pending_first.is_some() {
let first = self.pending_first.take().unwrap_or(Value::Null);
return self.poll_validated(first);
}
let line = {
let mut next = Box::pin(self.lines.next_line());
let polled = match std::future::Future::poll(next.as_mut(), cx) {
std::task::Poll::Ready(Ok(Some(line))) => Ok(Some(line)),
std::task::Poll::Ready(Ok(None)) => Ok(None),
std::task::Poll::Ready(Err(e)) => Err(e.message()),
std::task::Poll::Pending => {
drop(next);
return std::task::Poll::Pending;
}
};
drop(next);
match polled {
Ok(Some(line)) => line,
Ok(None) => return std::task::Poll::Ready(None),
Err(message) => {
self.done = true;
return std::task::Poll::Ready(Some(Err(CallError::invalid_input(message))));
}
}
};
let value = match serde_json::from_slice::<Value>(&line) {
Ok(v) => v,
Err(e) => {
self.done = true;
return std::task::Poll::Ready(Some(Err(CallError::invalid_input(format!(
"publish line is not valid JSON: {e}"
)))));
}
};
self.poll_validated(value)
}
}
fn subscribe_stream_from_envelope_stream(
stream: BoxStream<'static, ResponseEnvelope>,
) -> SubscribeStream {
Box::pin(stream.scan(false, |done, envelope| {
std::future::ready(if *done {
None
} else {
let item = match envelope.result {
Ok(output) => {
let data =
serde_json::to_string(&output).unwrap_or_else(|_| "null".to_string());
Event::default().retry(SSE_KEEP_ALIVE_INTERVAL).data(data)
}
Err(error) => {
*done = true;
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
let data =
serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
Event::default()
.event("error")
.retry(SSE_KEEP_ALIVE_INTERVAL)
.data(data)
}
};
Some(Ok::<_, Infallible>(item))
})
}))
}
fn keep_alive_event() -> Event {
Event::default().retry(SSE_KEEP_ALIVE_INTERVAL)
}
pub(crate) fn subscribe_stream_internal_error(operation: String) -> SubscribeStream {
Box::pin(stream::once(async move { error_event(&operation) }))
}
fn envelope_to_response(envelope: ResponseEnvelope, identity: Option<&Identity>) -> Response {
match envelope.result {
Ok(output) => {
let body = envelope_to_ok_json(&envelope.request_id, &output);
(StatusCode::OK, Json(body)).into_response()
}
Err(error) => call_error_to_http_response_with_identity(&error, identity),
}
}
fn discovery_get_response(envelope: ResponseEnvelope, identity: Option<&Identity>) -> Response {
with_no_cache_headers(envelope_to_response(envelope, identity))
}
fn with_no_cache_headers(mut response: Response) -> Response {
let headers = response.headers_mut();
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(VARY, HeaderValue::from_static("Authorization"));
response
}
fn envelope_to_json(envelope: ResponseEnvelope) -> Value {
match envelope.result {
Ok(output) => envelope_to_ok_json(&envelope.request_id, &output),
Err(error) => envelope_to_error_json(&envelope.request_id, &error),
}
}
fn envelope_to_ok_json(request_id: &str, output: &Value) -> Value {
json!({
"request_id": request_id,
"result": "ok",
"output": output,
})
}
fn envelope_to_error_json(request_id: &str, error: &CallError) -> Value {
json!({
"request_id": request_id,
"result": "error",
"error": serde_json::to_value(error).unwrap_or(Value::Null),
})
}
fn not_found_envelope_json(operation: &str) -> Value {
let error = CallError::not_found(operation);
json!({
"request_id": uuid::Uuid::new_v4().to_string(),
"result": "error",
"error": serde_json::to_value(&error).unwrap_or(Value::Null),
})
}
fn not_found_response(operation: &str) -> Response {
let error = CallError::not_found(operation);
call_error_to_http_response_with_identity(&error, None)
}
fn forbidden_response(message: String, identity: Option<&Identity>) -> Response {
let error = CallError::forbidden(message);
call_error_to_http_response_with_identity(&error, identity)
}
fn access_check_for_op(
registry: &OperationRegistry,
operation: &str,
identity: Option<&Identity>,
) -> Option<String> {
let name = operation.strip_prefix('/').unwrap_or(operation);
let reg = registry.registration(name)?;
if let AccessResult::Forbidden(message) = reg.spec.access_control.check(identity, None, None) {
return Some(message);
}
None
}
fn is_internal_op(registry: &OperationRegistry, operation: &str) -> bool {
let name = operation.strip_prefix('/').unwrap_or(operation);
match registry.registration(name) {
Some(reg) => reg.spec.visibility == Visibility::Internal,
None => false,
}
}
fn error_event(operation: &str) -> Result<Event, Infallible> {
let error = CallError::not_found(operation);
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
Ok(Event::default().event("error").data(data))
}
#[cfg(test)]
mod tests {
use super::*;
use alkcall::core::auth::IdentityProvider;
use alkcall::core::types::Capabilities;
use alkcall::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alkcall::registry::registration::{
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType};
use axum::body::Body;
use axum::http::Request;
use axum::middleware::from_fn_with_state;
use http_body_util::BodyExt;
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
use tower::ServiceExt;
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
}
impl StaticIdentityProvider {
fn new() -> Self {
Self {
tokens: StdMutex::new(HashMap::new()),
}
}
fn with_token(self, token: &str, identity: Identity) -> Self {
self.tokens
.lock()
.unwrap()
.insert(token.to_string(), identity);
self
}
}
impl IdentityProvider for StaticIdentityProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &alkcall::core::auth::AuthToken) -> Option<Identity> {
let token_str = String::from_utf8_lossy(&token.raw);
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
}
}
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
fn external_spec(name: &str, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![],
acl,
None,
)
}
fn internal_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::Internal,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
)
}
fn echo_handler() -> alkcall::registry::registration::Handler {
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) })
}
fn registry_with_echo() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec(
"admin/run",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_internal_op() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn subscription_spec(name: &str, visibility: Visibility, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Sub,
visibility,
json!({}),
json!({}),
vec![],
acl,
None,
)
}
fn multi_event_streaming_handler(
outputs: Vec<Value>,
) -> alkcall::registry::registration::StreamingHandler {
make_streaming_handler(move |_input, ctx| {
let request_id = ctx.request_id.clone();
let outputs = outputs.clone();
futures::stream::iter(
outputs
.into_iter()
.map(move |o| ResponseEnvelope::ok(request_id.clone(), o)),
)
})
}
fn error_streaming_handler(error: CallError) -> HandlerKind {
HandlerKind::Stream(make_streaming_handler(move |_input, ctx| {
let request_id = ctx.request_id.clone();
let error = error.clone();
futures::stream::iter(vec![ResponseEnvelope::error(request_id, error)])
}))
}
fn registry_with_subscription_stream(
name: &str,
outputs: Vec<Value>,
) -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
subscription_spec(name, Visibility::External, AccessControl::default()),
HandlerKind::Stream(multi_event_streaming_handler(outputs)),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_subscription_error(name: &str, error: CallError) -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
subscription_spec(name, Visibility::External, AccessControl::default()),
error_streaming_handler(error),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn registry_with_discovery_and_ops(
inner_ops: Vec<HandlerRegistration>,
) -> Arc<OperationRegistry> {
let inner = OperationRegistry::new();
for op in inner_ops {
inner.register(op).unwrap();
}
let inner = Arc::new(inner);
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
for spec in inner.list_operations() {
let name = spec.name.clone();
let reg = inner.registration(&name).unwrap();
registry
.register(HandlerRegistration::new(
reg.spec.clone(),
reg.handler.clone(),
reg.provenance,
reg.composition_authority.clone(),
reg.scoped_env.clone(),
reg.capabilities.clone(),
))
.unwrap();
}
Arc::new(registry)
}
fn registry_with_discovery_and_internal_op() -> Arc<OperationRegistry> {
let inner = OperationRegistry::new();
inner
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
inner
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let inner = Arc::new(inner);
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
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();
Arc::new(registry)
}
fn unused_provider() -> Arc<dyn IdentityProvider> {
Arc::new(StaticIdentityProvider::new())
}
fn build_router(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
) -> axum::Router {
let state = RouterState {
registry: Arc::clone(®istry),
identity_provider: Arc::clone(&provider),
decoy: crate::server::DecoyConfig::NotFound,
openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(®istry),
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
ws_session_slots: Arc::new(tokio::sync::Semaphore::new(
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
)),
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
ws_openable_alpns: None,
ws_op_register_acl: alkcall::registry::spec::AccessControl::default(),
};
let auth_state = Arc::clone(&provider);
gateway_router()
.route_layer(from_fn_with_state(
auth_state,
crate::server::auth::bearer_auth_middleware,
))
.with_state(state)
}
fn auth_header(token: &str) -> (&'static str, String) {
("authorization", format!("Bearer {token}"))
}
async fn send(router: axum::Router, req: Request<Body>) -> (StatusCode, Value) {
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, body)
}
fn json_request(method: &str, uri: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
#[tokio::test]
async fn call_round_trip_external_op_returns_200_with_json_body() {
let router = build_router(registry_with_echo(), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "echo/run", "input": { "msg": "hi" } }),
);
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.get("result"), Some(&json!("ok")));
assert_eq!(body.get("output"), Some(&json!({ "msg": "hi" })));
}
#[tokio::test]
async fn call_internal_op_returns_404() {
let router = build_router(registry_with_internal_op(), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "secret/op", "input": {} }),
);
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 call_unauthorized_restricted_op_returns_403() {
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("user", &["user"])),
);
let router = build_router(registry_with_restricted_op(), provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.header(k, v)
.body(Body::from(
serde_json::to_vec(&json!({ "operation": "admin/run", "input": {} })).unwrap(),
))
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn call_unauthenticated_restricted_op_returns_401() {
let router = build_router(registry_with_restricted_op(), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "admin/run", "input": {} }),
);
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn search_returns_only_access_control_allowed_ops() {
let ops = vec![
HandlerRegistration::new(
external_spec("public/echo", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
];
let discovery = registry_with_discovery_and_ops(ops);
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("regular", &["user"])),
);
let router = build_router(discovery, provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("GET")
.uri("/search")
.header(k, v)
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let ops = body
.get("output")
.and_then(|o| o.get("operations"))
.and_then(|o| o.as_array())
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"public/echo"));
assert!(!names.contains(&"admin/secret"));
}
#[tokio::test]
async fn schema_returns_full_spec_for_authorized_op() {
let ops = vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/schema?name=echo%2Frun")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let output = body.get("output").expect("output");
assert_eq!(output.get("name"), Some(&json!("echo/run")));
assert_eq!(output.get("namespace"), Some(&json!("echo")));
assert!(output.get("input_schema").is_some());
assert!(output.get("output_schema").is_some());
}
#[tokio::test]
async fn schema_for_unauthorized_op_returns_403() {
let ops = vec![HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("regular", &["user"])),
);
let router = build_router(discovery, provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("GET")
.uri("/schema?name=admin%2Fsecret")
.header(k, v)
.body(Body::empty())
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn schema_unknown_op_returns_404() {
let ops = vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/schema?name=no%2Fsuch")
.body(Body::empty())
.unwrap();
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 schema_internal_op_returns_404_unauthenticated() {
let router = build_router(registry_with_internal_op(), unused_provider());
let req = Request::builder()
.method("GET")
.uri("/schema?name=secret%2Fop")
.body(Body::empty())
.unwrap();
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 schema_internal_op_returns_404_for_unauthorized_identity() {
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("user", &["user"])),
);
let router = build_router(registry_with_internal_op(), provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("GET")
.uri("/schema?name=secret%2Fop")
.header(k, v)
.body(Body::empty())
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn schema_internal_op_returns_404_for_anonymous_identity() {
let router = build_router(registry_with_internal_op(), unused_provider());
let (k, v) = auth_header("unknown-tok");
let req = Request::builder()
.method("GET")
.uri("/schema?name=secret%2Fop")
.header(k, v)
.body(Body::empty())
.unwrap();
let (status, _body) = send(router, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn call_services_schema_with_internal_name_returns_404() {
let router = build_router(registry_with_internal_op(), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "services/schema", "input": { "name": "secret/op" } }),
);
let (status, body) = send(router, req).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"the op path must deny a spec the GET /schema route denies (PRJ-16): {body}"
);
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
assert!(
body.get("input_schema").is_none() && body.get("output").is_none(),
"the spec must not leak in any form: {body}"
);
}
#[tokio::test]
async fn call_services_schema_with_acl_restricted_name_returns_401_unauthenticated() {
let discovery = registry_with_discovery_and_ops(vec![HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)]);
let router = build_router(discovery, unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "services/schema", "input": { "name": "admin/secret" } }),
);
let (status, body) = send(router, req).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"FORBIDDEN with no identity maps to 401 (gateway error mapping): {body}"
);
assert_eq!(body.get("code"), Some(&json!("FORBIDDEN")));
}
#[tokio::test]
async fn call_services_schema_with_acl_restricted_name_returns_403_for_unauthorized_identity() {
let discovery = registry_with_discovery_and_ops(vec![HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)]);
let provider: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("user-tok", identity_with_scopes("user", &["user"])),
);
let router = build_router(discovery, provider);
let (k, v) = auth_header("user-tok");
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.header(k, v)
.body(Body::from(
serde_json::to_vec(
&json!({ "operation": "services/schema", "input": { "name": "admin/secret" } }),
)
.unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"an identity the GET /schema route would deny must be denied identically: {body}"
);
assert_eq!(body.get("code"), Some(&json!("FORBIDDEN")));
}
#[tokio::test]
async fn call_services_schema_authorized_name_still_round_trips() {
let ops = vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "services/schema", "input": { "name": "echo/run" } }),
);
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let output = body
.get("output")
.and_then(|o| o.get("name"))
.cloned()
.unwrap_or(Value::Null);
assert_eq!(output, json!("echo/run"));
}
#[tokio::test]
async fn batch_services_schema_with_internal_name_yields_not_found_entry() {
let discovery = registry_with_discovery_and_internal_op();
let router = build_router(discovery, unused_provider());
let req = json_request(
"POST",
"/batch",
json!([
{ "operation": "services/schema", "input": { "name": "secret/op" } },
{ "operation": "services/schema", "input": { "name": "echo/run" } },
]),
);
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(), 2);
assert_eq!(results[0].get("result"), Some(&json!("error")));
assert_eq!(
results[0]
.get("error")
.and_then(|e| e.get("code"))
.cloned()
.unwrap_or(Value::Null),
json!("NOT_FOUND")
);
assert_eq!(results[1].get("result"), Some(&json!("ok")));
}
#[tokio::test]
async fn subscribe_on_services_schema_internal_name_emits_not_found_event() {
let discovery = registry_with_discovery_and_internal_op();
let router = build_router(discovery, unused_provider());
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "services/schema", "input": { "name": "secret/op" } }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("event:error") || body.contains("event: error"),
"expected the guard's error event on the streaming path, got: {body}"
);
assert!(
body.contains("NOT_FOUND"),
"expected NOT_FOUND from the op-path guard, got: {body}"
);
assert!(
!body.contains("input_schema"),
"the inner op's spec must not leak through the streaming path: {body}"
);
}
#[tokio::test]
async fn batch_returns_array_of_results_in_order() {
let router = build_router(registry_with_echo(), unused_provider());
let req = json_request(
"POST",
"/batch",
json!([
{ "operation": "echo/run", "input": { "n": 1 } },
{ "operation": "echo/run", "input": { "n": 2 } },
]),
);
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(), 2);
assert_eq!(results[0].get("output"), Some(&json!({ "n": 1 })));
assert_eq!(results[1].get("output"), Some(&json!({ "n": 2 })));
}
#[tokio::test]
async fn batch_internal_op_returns_not_found_in_array() {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
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();
let router = build_router(Arc::new(registry), unused_provider());
let req = json_request(
"POST",
"/batch",
json!([
{ "operation": "echo/run", "input": {} },
{ "operation": "secret/op", "input": {} },
]),
);
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(), 2);
assert_eq!(results[0].get("result"), Some(&json!("ok")));
assert_eq!(results[1].get("result"), Some(&json!("error")));
assert_eq!(
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_422_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::UNPROCESSABLE_ENTITY);
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]
async fn subscribe_on_subscription_streams_multiple_data_frames() {
let router = build_router(
registry_with_subscription_stream(
"events/stream",
vec![json!({ "n": 1 }), json!({ "n": 2 }), json!({ "n": 3 })],
),
unused_provider(),
);
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "events/stream", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap().to_string());
assert!(
ctype
.as_deref()
.unwrap_or("")
.starts_with("text/event-stream"),
"expected text/event-stream, got {ctype:?}"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
let data_frames = body.matches("data:").count();
assert_eq!(data_frames, 3, "expected 3 data frames, got: {body}");
assert!(body.contains("\"n\":1"), "expected n=1, got: {body}");
assert!(body.contains("\"n\":2"), "expected n=2, got: {body}");
assert!(body.contains("\"n\":3"), "expected n=3, got: {body}");
}
#[tokio::test]
async fn subscribe_on_subscription_that_yields_error_emits_error_event_then_closes() {
let router = build_router(
registry_with_subscription_error("events/fail", CallError::internal("handler blew up")),
unused_provider(),
);
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "events/fail", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("event:error") || body.contains("event: error"),
"expected error event, got: {body}"
);
assert!(
body.contains("INTERNAL"),
"expected INTERNAL code, got: {body}"
);
assert!(
body.contains("handler blew up"),
"expected error message, got: {body}"
);
let data_frames = body.matches("data:").count();
assert_eq!(
data_frames, 1,
"expected exactly one data frame (the error payload), got: {body}"
);
}
#[tokio::test]
async fn subscribe_stream_is_terminal_after_an_error_event() {
let router = build_router(
registry_with_subscription_stream_continuing_after_error(
"events/continue",
CallError::internal("mid-stream failure"),
),
unused_provider(),
);
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "events/continue", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
let error_events = body.matches("event:").count();
assert_eq!(error_events, 1, "exactly one error event, got: {body}");
let data_frames = body.matches("data:").count();
assert_eq!(
data_frames, 1,
"the error frame is the last event — no post-error data frames, got: {body}"
);
assert!(
!body.contains("\"after\":true"),
"the post-error envelope must not reach the wire, got: {body}"
);
}
#[tokio::test]
async fn subscribe_stream_carries_retry_field_and_keep_alive_comment() {
let router = build_router(
registry_with_subscription_stream("events/quiet", vec![json!({ "n": 1 })]),
unused_provider(),
);
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "events/quiet", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("retry: 15000"),
"expected a retry: hint on stream events, got: {body}"
);
assert!(
body.contains(':'),
"expected a keep-alive comment frame, got: {body}"
);
}
fn registry_with_subscription_stream_continuing_after_error(
name: &str,
error: CallError,
) -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
subscription_spec(name, Visibility::External, AccessControl::default()),
HandlerKind::Stream(make_streaming_handler(move |_input, ctx| {
let request_id = ctx.request_id.clone();
let error = error.clone();
futures::stream::iter(vec![
ResponseEnvelope::error(request_id.clone(), error),
ResponseEnvelope::ok(request_id, json!({ "after": true })),
])
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
#[tokio::test]
async fn subscribe_response_content_type_is_text_event_stream() {
let router = build_router(
registry_with_subscription_stream("events/stream", vec![json!({ "ok": true })]),
unused_provider(),
);
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "events/stream", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
let ctype = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(
ctype.as_deref(),
Some("text/event-stream"),
"expected text/event-stream, got {ctype:?}"
);
}
#[tokio::test]
async fn subscribe_internal_op_emits_error_event() {
let router = build_router(registry_with_internal_op(), unused_provider());
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "secret/op", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("event:error") || body.contains("event: error"),
"expected error event, got: {body}"
);
assert!(
body.contains("NOT_FOUND"),
"expected NOT_FOUND, got: {body}"
);
}
#[tokio::test]
async fn subscribe_unknown_op_emits_not_found_error_event() {
let router = build_router(
registry_with_subscription_stream("events/stream", vec![json!({})]),
unused_provider(),
);
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "no/such", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("event:error") || body.contains("event: error"),
"expected error event, got: {body}"
);
assert!(
body.contains("NOT_FOUND"),
"expected NOT_FOUND, got: {body}"
);
}
#[tokio::test]
async fn subscribe_on_query_op_emits_invalid_operation_type_error_event() {
let router = build_router(registry_with_echo(), unused_provider());
let req = json_request(
"POST",
"/subscribe",
json!({ "operation": "echo/run", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("event:error") || body.contains("event: error"),
"expected error event, got: {body}"
);
assert!(
body.contains("INVALID_OPERATION_TYPE"),
"expected INVALID_OPERATION_TYPE, got: {body}"
);
}
#[test]
fn is_internal_op_returns_false_for_unknown() {
let registry = OperationRegistry::new();
assert!(!is_internal_op(®istry, "no/such"));
assert!(!is_internal_op(®istry, "/no/such"));
}
#[test]
fn is_internal_op_detects_registered_internal_op() {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
assert!(is_internal_op(®istry, "secret/op"));
assert!(is_internal_op(®istry, "/secret/op"));
}
#[test]
fn is_internal_op_false_for_external_op() {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
assert!(!is_internal_op(®istry, "echo/run"));
}
#[test]
fn envelope_to_ok_json_shape() {
let env = ResponseEnvelope::ok("req-1", json!({ "v": 1 }));
let v = envelope_to_json(env);
assert_eq!(v.get("request_id"), Some(&json!("req-1")));
assert_eq!(v.get("result"), Some(&json!("ok")));
assert_eq!(v.get("output"), Some(&json!({ "v": 1 })));
}
#[test]
fn envelope_to_error_json_shape() {
let env = ResponseEnvelope::not_found("req-2", "no/such");
let v = envelope_to_json(env);
assert_eq!(v.get("result"), Some(&json!("error")));
assert_eq!(
v.get("error").and_then(|e| e.get("code")),
Some(&json!("NOT_FOUND"))
);
}
#[tokio::test]
async fn call_error_envelope_carries_retry_after_on_retryable_503() {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("flaky/op", AccessControl::default()),
HandlerKind::Once(make_handler(|_input, ctx| async move {
ResponseEnvelope::error(
ctx.request_id,
CallError::new("HTTP_503", "overloaded", true)
.with_details(json!({ "retry_after": "30" })),
)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let router = build_router(Arc::new(registry), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "flaky/op", "input": {} }),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let retry_after = resp
.headers()
.get(axum::http::header::RETRY_AFTER)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(
retry_after.as_deref(),
Some("30"),
"a retryable HTTP_503 from a handler must carry Retry-After on the gateway error path"
);
}
#[tokio::test]
async fn call_with_leading_slash_in_operation_dispatches() {
let router = build_router(registry_with_echo(), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "/echo/run", "input": {} }),
);
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.get("result"), Some(&json!("ok")));
}
#[tokio::test]
async fn call_unknown_op_returns_404() {
let router = build_router(registry_with_echo(), unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "no/such", "input": {} }),
);
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 search_unauthenticated_lists_default_acl_ops_only() {
let ops = vec![
HandlerRegistration::new(
external_spec("public/echo", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
),
];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/search")
.body(Body::empty())
.unwrap();
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
let ops = body
.get("output")
.and_then(|o| o.get("operations"))
.and_then(|o| o.as_array())
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"public/echo"));
assert!(!names.contains(&"admin/secret"));
}
#[tokio::test]
async fn gateway_router_mounts_at_expected_paths() {
let ops = vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = json_request(
"POST",
"/call",
json!({ "operation": "echo/run", "input": {} }),
);
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method("GET")
.uri("/search")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_and_schema_carry_no_store_and_vary_authorization() {
let discovery = registry_with_discovery_and_ops(vec![HandlerRegistration::new(
external_spec("echo/run", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)]);
let router = build_router(discovery, unused_provider());
for uri in ["/search", "/schema?name=echo%2Frun"] {
let req = Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let cache_control = resp
.headers()
.get(axum::http::header::CACHE_CONTROL)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(
cache_control.as_deref(),
Some("no-store"),
"GET {uri} must not be cacheable (GW-02)"
);
let vary = resp
.headers()
.get(axum::http::header::VARY)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(
vary.as_deref(),
Some("Authorization"),
"GET {uri} is per-identity; it must Vary on Authorization (GW-02)"
);
}
}
#[tokio::test]
async fn schema_denials_carry_no_store_and_vary_authorization() {
let ops = vec![HandlerRegistration::new(
external_spec(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
)];
let discovery = registry_with_discovery_and_ops(ops);
let router = build_router(discovery, unused_provider());
let req = Request::builder()
.method("GET")
.uri("/schema?name=admin%2Fsecret")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"FORBIDDEN with no identity maps to 401 (gateway error mapping)"
);
assert_eq!(
resp.headers()
.get(axum::http::header::CACHE_CONTROL)
.map(|v| v.to_str().unwrap()),
Some("no-store")
);
assert_eq!(
resp.headers()
.get(axum::http::header::VARY)
.map(|v| v.to_str().unwrap()),
Some("Authorization")
);
}
use alkcall::registry::registration::make_sink_handler;
fn publish_registry() -> Arc<OperationRegistry> {
let 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, 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(
"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(
"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 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_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::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")));
}
#[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_422() {
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::UNPROCESSABLE_ENTITY);
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
}
#[tokio::test]
async fn publish_empty_body_returns_422() {
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::UNPROCESSABLE_ENTITY);
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
}
#[tokio::test]
async fn publish_first_line_invalid_json_returns_422_invalid_input() {
let router = build_router(publish_registry(), unused_provider());
let req = raw_request("POST", "/publish", b"not-json-at-all\n".to_vec());
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
let message = body.get("message").and_then(Value::as_str).unwrap_or("");
assert!(
message.contains("not valid JSON") || message.contains("JSON"),
"the 422 must name the JSON parse failure: {message}"
);
}
#[tokio::test]
async fn publish_first_line_missing_chunk_returns_422_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::UNPROCESSABLE_ENTITY);
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::UNPROCESSABLE_ENTITY);
assert_eq!(
resp.get("code"),
Some(&json!("INVALID_INPUT")),
"the malformed chunk line terminates the stream as INVALID_INPUT: {resp}"
);
}
#[tokio::test]
async fn publish_error_envelope_maps_to_http_status() {
let 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_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 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::UNPROCESSABLE_ENTITY);
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}"
);
}
fn registry_with_cap_witness_sink() -> Arc<OperationRegistry> {
let 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("a capped line must produce an error item");
ResponseEnvelope::error(ctx.request_id, error)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
#[tokio::test]
async fn publish_streamed_never_newline_body_over_cap_is_rejected_pre_extend() {
let router = build_router(registry_with_cap_witness_sink(), unused_provider());
let first_line = serde_json::to_vec(&json!({
"operation": "ingest/big",
"chunk": { "n": 1 }
}))
.unwrap();
let mut body = first_line;
body.push(b'\n');
body.extend_from_slice(&vec![b'a'; MAX_PUBLISH_LINE_BYTES + 1]);
let chunks: Vec<Bytes> = body.chunks(64 * 1024).map(Bytes::copy_from_slice).collect();
let req = Request::builder()
.method("POST")
.uri("/publish")
.header("content-type", "application/x-ndjson")
.header("transfer-encoding", "chunked")
.body(Body::from_stream(futures::stream::iter(
chunks.into_iter().map(Ok::<_, std::convert::Infallible>),
)))
.unwrap();
let (status, resp) = send(router, req).await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"a streamed never-newline body over the cap must surface the line cap before \
any further chunk: {resp}"
);
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 pre-extend line-cap message, got: {resp}"
);
assert_eq!(resp.get("retryable"), Some(&json!(false)));
}
#[tokio::test]
async fn publish_eof_without_newline_over_cap_is_rejected() {
let router = build_router(registry_with_cap_witness_sink(), unused_provider());
let first_line = serde_json::to_vec(&json!({
"operation": "ingest/big",
"chunk": { "n": 1 }
}))
.unwrap();
let mut body = first_line;
body.push(b'\n');
body.extend_from_slice(&vec![b'a'; MAX_PUBLISH_LINE_BYTES + 1]);
let (status, resp) = send(router, raw_request("POST", "/publish", body)).await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"EOF with an over-cap unterminated tail must not yield the buffer: {resp}"
);
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_body_over_gateway_limit_returns_413() {
let router = build_router(publish_registry(), unused_provider());
let mut body = serde_json::to_vec(&json!({
"operation": "ingest/push",
"chunk": { "n": 0 }
}))
.unwrap();
body.push(b'\n');
let filler = vec![b'a'; 4096 - 10];
for _ in 0..600 {
body.extend_from_slice(b"{\"n\":\"");
body.extend_from_slice(&filler);
body.extend_from_slice(b"\"}\n");
}
let chunks: Vec<Bytes> = body.chunks(64 * 1024).map(Bytes::copy_from_slice).collect();
let req = Request::builder()
.method("POST")
.uri("/publish")
.header("content-type", "application/x-ndjson")
.body(Body::from_stream(futures::stream::iter(
chunks.into_iter().map(Ok::<_, std::convert::Infallible>),
)))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"a chunked upload of many under-cap lines over the body limit must be cut off with 413"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
&bytes[..],
GATEWAY_BODY_LIMIT_EXCEEDED.as_bytes(),
"the layer answers plain text 413, not an error envelope"
);
}
#[tokio::test]
async fn publish_declared_content_length_over_gateway_limit_returns_413() {
let router = build_router(publish_registry(), unused_provider());
let req = Request::builder()
.method("POST")
.uri("/publish")
.header("content-type", "application/x-ndjson")
.header("content-length", (GATEWAY_BODY_LIMIT + 1).to_string())
.body(Body::from(vec![b'a'; 64 * 1024]))
.unwrap();
let (status, resp) = send(router, req).await;
assert_eq!(
status,
StatusCode::PAYLOAD_TOO_LARGE,
"a declared content-length over the limit must be pre-rejected with 413"
);
assert_eq!(
resp,
Value::Null,
"the layer answers plain text 413, not an error envelope: {resp}"
);
}
#[tokio::test]
async fn publish_line_cap_breach_when_batched_with_complete_lines_is_still_rejected() {
let router = build_router(registry_with_cap_witness_sink(), unused_provider());
let mut body = ndjson(&[
json!({ "operation": "ingest/big", "chunk": { "n": 1 } }),
json!({ "n": 2 }),
]);
body.extend_from_slice(&vec![b'a'; MAX_PUBLISH_LINE_BYTES + 1]);
body.push(b'\n');
let (status, resp) = send(router, raw_request("POST", "/publish", body)).await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"an over-cap line batched with complete lines must still abort the request with the \
cap error (GW-16 unifies it with the mid-stream 422): {resp}"
);
assert_eq!(resp.get("code"), Some(&json!("INVALID_INPUT")));
}
#[tokio::test]
async fn publish_body_at_line_cap_within_limit_still_round_trips() {
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;
body.push(b'\n');
body.extend_from_slice(b"\"");
body.extend_from_slice(&vec![b'a'; MAX_PUBLISH_LINE_BYTES - 2]);
body.extend_from_slice(b"\"");
body.push(b'\n');
let (status, resp) = send(router, raw_request("POST", "/publish", body)).await;
assert_eq!(
status,
StatusCode::OK,
"a body under the limit with at-cap lines must pass the layer: {resp}"
);
assert_eq!(
resp.get("result"),
Some(&json!("ok")),
"a second line exactly at the per-line cap must be accepted (the cap is >, not >=): {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}"
);
}
#[test]
fn uncompilable_publish_schema_is_rejected_at_registration() {
let registry = OperationRegistry::new();
let result = registry.register(HandlerRegistration::new(
OperationSpec::new(
"ingest/broken",
OperationType::Pub,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
None,
)
.with_publish_schema(json!({ "required": "n" })), HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move {
ResponseEnvelope::ok(ctx.request_id, Value::Null)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
));
assert!(
result.is_err(),
"an un-compilable publish_schema must be rejected at registration"
);
assert!(
registry.registration("ingest/broken").is_none(),
"the un-compilable op must not be registered"
);
}
#[tokio::test]
async fn publish_hot_reload_replacement_schema_is_picked_up() {
let registry = OperationRegistry::new();
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(|input, ctx, _chunks| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let v1 = registry
.publish_validator("ingest/typed")
.expect("compiled validator");
assert!(!v1.is_valid(&json!({ "n": 1, "extra": true })));
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"]
})),
HandlerKind::Sink(make_sink_handler(|input, ctx, _chunks| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let v2 = registry
.publish_validator("ingest/typed")
.expect("recompiled validator");
assert!(
v2.is_valid(&json!({ "n": 1, "extra": true })),
"the recompiled schema must reflect the new registration"
);
}
}