Files
alkhttp/src/gateway/dispatch.rs
T
glm-5.3-flash 8611920fdb Merge branch 'wt/review-002-cov13-dead-code'
# Conflicts:
#	src/gateway/dispatch.rs
2026-08-30 23:07:39 +00:00

378 lines
14 KiB
Rust

//! Shared dispatch spine for the `to_openapi` / `to_mcp` gateway
//! projections.
//!
//! Thin concrete struct (not a trait — the alknet research ruled out a
//! trait with an associated output type). Holds `Arc<OperationRegistry>`
//! and exposes an `invoke()` family returning the neutral
//! `ResponseEnvelope` — identity is supplied per-call, resolved upstream
//! in the auth middleware. Each
//! gateway maps the envelope to its own wire shape (`to_openapi` → HTTP
//! `Response`, `to_mcp` → `CallToolResult`).
//!
//! # Security invariants
//!
//! - `internal: false` — ACL runs against the caller's `identity`, not a
//! handler's composition authority (alkcall ADR-017).
//! - `forwarded_for: None` — wire-ingress only.
//!
//! The root `OperationContext` is constructed identically for both
//! gateways, making them provably identical on the security axis (auth,
//! authority, ACL); they diverge only on wire-framing.
//!
//! # Deadline
//!
//! Once-op invokes ([`GatewayDispatch::invoke`]) are bounded by
//! `DEFAULT_TIMEOUT` (30 s): the registry invoke is wrapped in
//! `tokio::time::timeout` and a hung handler surfaces as a `TIMEOUT`
//! error envelope (`504` under the gateway's error mapping), not an
//! indefinitely-held HTTP request. Streaming and sink dispatch set
//! `deadline: None` (subscriptions are unbounded per alkcall ADR-021,
//! and a `/publish` body is bounded by the client's upload, not a
//! fixed window).
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use alkcall::core::auth::Identity;
use alkcall::core::types::Capabilities;
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
use alkcall::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use alkcall::registry::env::LocalOperationEnv;
use alkcall::registry::registration::OperationRegistry;
use futures::stream::BoxStream;
use serde_json::Value;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// The shared dispatch spine over the registry, invoking operations for
/// the neutral `ResponseEnvelope` result shape both gateway projections
/// map to their wire formats. Identity arrives per-call as
/// `Option<Identity>` — bearer resolution happens upstream in the auth
/// middleware, not here.
pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
invoke_count: AtomicUsize,
}
impl GatewayDispatch {
/// Assemble a dispatch spine over a registry.
pub fn new(registry: Arc<OperationRegistry>) -> Self {
Self {
registry,
invoke_count: AtomicUsize::new(0),
}
}
/// The registry operations resolve against.
pub fn registry(&self) -> &Arc<OperationRegistry> {
&self.registry
}
/// How many [`GatewayDispatch::invoke`] calls this spine has
/// served. A test-spy accessor: the over-cap batch tests assert it
/// stays at zero to prove no dispatch happened before the cap
/// rejection (review-002 PRJ-22).
pub fn invoke_count(&self) -> usize {
self.invoke_count.load(Ordering::Relaxed)
}
/// Invoke a Query/Mutation op under the 30 s gateway deadline; a
/// hung handler surfaces as a `TIMEOUT` error envelope (504).
pub async fn invoke(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> ResponseEnvelope {
self.invoke_count.fetch_add(1, Ordering::Relaxed);
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
let context = self.build_root_context(&request_id, &operation_name, identity);
let result = tokio::time::timeout(
DEFAULT_TIMEOUT,
self.registry.invoke(&operation_name, input, context),
)
.await;
match result {
Ok(envelope) => envelope,
Err(_elapsed) => ResponseEnvelope::error(
request_id,
CallError::timeout(format!(
"operation did not complete within the {DEFAULT_TIMEOUT:?} gateway deadline"
)),
),
}
}
/// Dispatch a Sub op: the returned stream of envelopes is unbounded
/// by the deadline (subscriptions are long-lived per alkcall
/// ADR-021); pre-handler failures surface as one error envelope.
pub fn invoke_streaming(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> BoxStream<'static, ResponseEnvelope> {
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
let context = self.build_root_context_streaming(&request_id, &operation_name, identity);
self.registry
.invoke_streaming(&operation_name, input, context)
}
/// Dispatch a `Pub` operation (ADR-046, ADR-068): the
/// `publish_stream` is the initiator's chunk stream (one
/// `Ok(Value)` per published chunk); the handler's final
/// `ResponseEnvelope` is the result. Pre-handler failures
/// (not-found, forbidden, non-Pub op) surface as a single error
/// envelope — the same envelope the `/publish` route maps to HTTP.
pub async fn invoke_sink(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
publish_stream: alkcall::registry::registration::PublishStream,
) -> ResponseEnvelope {
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
self.registry
.invoke_sink(&operation_name, input, publish_stream, context)
.await
}
fn build_root_context_sink(
&self,
request_id: &str,
operation_name: &str,
identity: Option<Identity>,
) -> OperationContext {
self.build_root_context_inner(request_id, operation_name, identity, false)
}
fn build_root_context(
&self,
request_id: &str,
operation_name: &str,
identity: Option<Identity>,
) -> OperationContext {
self.build_root_context_inner(request_id, operation_name, identity, true)
}
fn build_root_context_streaming(
&self,
request_id: &str,
operation_name: &str,
identity: Option<Identity>,
) -> OperationContext {
self.build_root_context_inner(request_id, operation_name, identity, false)
}
fn build_root_context_inner(
&self,
request_id: &str,
operation_name: &str,
identity: Option<Identity>,
bounded: bool,
) -> OperationContext {
let registration = self.registry.registration(operation_name);
let (composition_authority, capabilities, scoped_env) = match registration {
Some(r) => (
r.composition_authority.clone(),
r.capabilities.clone(),
r.scoped_env.clone().unwrap_or_else(ScopedPeerEnv::empty),
),
None => (None, Capabilities::new(), ScopedPeerEnv::empty()),
};
let env: Arc<dyn alkcall::registry::env::OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
OperationContext {
request_id: request_id.to_string(),
parent_request_id: None,
identity,
handler_identity: composition_authority,
forwarded_for: None,
capabilities,
metadata: HashMap::new(),
deadline: bounded.then(|| Instant::now() + DEFAULT_TIMEOUT),
scoped_env,
env,
abort_policy: AbortPolicy::default(),
internal: false,
ownership: None,
}
}
}
fn strip_leading_slash(operation_id: &str) -> &str {
operation_id.strip_prefix('/').unwrap_or(operation_id)
}
#[cfg(test)]
mod tests {
use super::*;
use alkcall::registry::registration::{
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use futures::StreamExt;
// The tests below run against the module's real 30 s deadline; only
// the elapsed-time bound (< 60 s, anti-flake) is asserted by hand.
fn spec(name: &str, visibility: Visibility, op_type: OperationType) -> OperationSpec {
OperationSpec::new(
name,
op_type,
visibility,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
fn echo_registry() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
spec("echo/run", Visibility::External, OperationType::Query),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
#[tokio::test]
async fn invoke_external_op_round_trips() {
let dispatch = GatewayDispatch::new(echo_registry());
let envelope = dispatch
.invoke(None, "/echo/run", serde_json::json!({ "x": 1 }))
.await;
assert!(envelope.result.is_ok(), "expected ok, got {envelope:?}");
}
#[tokio::test]
async fn invoke_unknown_op_returns_not_found() {
let dispatch = GatewayDispatch::new(echo_registry());
let envelope = dispatch
.invoke(None, "/missing/op", serde_json::json!({}))
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
Ok(v) => panic!("expected error, got {v:?}"),
}
}
#[tokio::test]
async fn invoke_internal_op_returns_not_found() {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
spec("internal/op", Visibility::Internal, OperationType::Query),
HandlerKind::Once(make_handler(|_input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let dispatch = GatewayDispatch::new(Arc::new(registry));
let envelope = dispatch
.invoke(None, "/internal/op", serde_json::json!({}))
.await;
match envelope.result {
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
Ok(v) => panic!("expected error, got {v:?}"),
}
}
#[tokio::test]
async fn invoke_enforces_the_default_deadline_on_a_hung_handler() {
use std::time::Duration;
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
spec("hung/op", Visibility::External, OperationType::Query),
HandlerKind::Once(make_handler(|_input, ctx| async move {
tokio::time::sleep(Duration::from_secs(120)).await;
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let dispatch = GatewayDispatch::new(Arc::new(registry));
let started = std::time::Instant::now();
let envelope = dispatch
.invoke(None, "/hung/op", serde_json::json!({}))
.await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(60),
"the 30 s deadline must fire well before the 120 s handler sleep, took {elapsed:?}"
);
match envelope.result {
Err(error) => {
assert_eq!(error.code, "TIMEOUT");
assert!(error.retryable, "the deadline error is retryable");
}
Ok(v) => panic!("expected a TIMEOUT error, got {v:?}"),
}
}
#[tokio::test]
async fn invoke_completes_within_the_deadline_for_a_fast_handler() {
let dispatch = GatewayDispatch::new(echo_registry());
let envelope = dispatch
.invoke(None, "/echo/run", serde_json::json!({}))
.await;
assert!(envelope.result.is_ok(), "a fast handler must not time out");
}
#[tokio::test]
async fn streaming_sub_op_streams_envelopes() {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
spec("tick/stream", Visibility::External, OperationType::Sub),
HandlerKind::Stream(make_streaming_handler(|input, ctx| {
let count = input.get("count").and_then(|v| v.as_u64()).unwrap_or(2);
let request_id = ctx.request_id.clone();
futures::stream::iter(0..count).map(move |i| {
ResponseEnvelope::ok(request_id.clone(), serde_json::json!({ "tick": i }))
})
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let dispatch = GatewayDispatch::new(Arc::new(registry));
let mut stream =
dispatch.invoke_streaming(None, "/tick/stream", serde_json::json!({ "count": 3 }));
let mut ticks = Vec::new();
while let Some(envelope) = stream.next().await {
ticks.push(envelope);
}
assert_eq!(ticks.len(), 3);
}
}