Compare commits
12 Commits
0b0bceec7a
...
feat/call/
| Author | SHA1 | Date | |
|---|---|---|---|
| 67b1adba98 | |||
| 185ddb82b5 | |||
| 9c81129f24 | |||
| 07f7607fbb | |||
| 7ecc11610a | |||
| 139c651eaa | |||
| 5a51734291 | |||
| b3ab6ef097 | |||
| ccaac7e157 | |||
| 18156ac9d2 | |||
| dd6aacc598 | |||
| 2695a19502 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
target/
|
||||
node_modules/
|
||||
node_modules/
|
||||
.worktrees/
|
||||
@@ -572,7 +572,7 @@ mod tests {
|
||||
use crate::protocol::connection::CallConnection;
|
||||
use crate::protocol::wire::ResponseEnvelope;
|
||||
use crate::registry::registration::{
|
||||
make_handler, Handler, HandlerRegistration, OperationProvenance,
|
||||
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::Identity;
|
||||
@@ -640,14 +640,16 @@ mod tests {
|
||||
|
||||
fn registry_with_caps() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("pub/run"),
|
||||
caps_inspect_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "pub-key".to_string()),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("pub/run"),
|
||||
HandlerKind::Once(caps_inspect_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "pub-key".to_string()),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -709,7 +711,9 @@ mod tests {
|
||||
let client = CallClient::new(Arc::clone(®istry), Arc::new(NoopIdentityProvider));
|
||||
let conn = client.spawn_dispatch(stub_connection());
|
||||
assert_eq!(
|
||||
conn.connection().expect("quic connection present").remote_alpn(),
|
||||
conn.connection()
|
||||
.expect("quic connection present")
|
||||
.remote_alpn(),
|
||||
b"alknet/call"
|
||||
);
|
||||
std::mem::drop(conn);
|
||||
|
||||
@@ -19,7 +19,9 @@ use crate::client::AdapterError;
|
||||
use crate::protocol::connection::CallConnection;
|
||||
use crate::protocol::wire::ResponseEnvelope;
|
||||
use crate::registry::context::OperationContext;
|
||||
use crate::registry::registration::{Handler, HandlerRegistration, OperationProvenance};
|
||||
use crate::registry::registration::{
|
||||
Handler, HandlerKind, HandlerRegistration, OperationProvenance, StreamingHandler,
|
||||
};
|
||||
use crate::registry::spec::{
|
||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
@@ -121,14 +123,23 @@ fn build_bundles(
|
||||
});
|
||||
}
|
||||
|
||||
let handler = make_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
op_summary.credentials_auth_token.clone(),
|
||||
);
|
||||
let kind = match spec.op_type {
|
||||
OperationType::Subscription => HandlerKind::Stream(make_streaming_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
op_summary.credentials_auth_token.clone(),
|
||||
)),
|
||||
OperationType::Query | OperationType::Mutation => {
|
||||
HandlerKind::Once(make_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
op_summary.credentials_auth_token.clone(),
|
||||
))
|
||||
}
|
||||
};
|
||||
bundles.push(HandlerRegistration::new(
|
||||
spec,
|
||||
handler,
|
||||
kind,
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -309,8 +320,10 @@ fn parse_access_control(v: &Value) -> AccessControl {
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a forwarding handler for a `FromCall` leaf: on invocation, calls
|
||||
/// the remote op via the `CallConnection` and returns its `ResponseEnvelope`.
|
||||
/// Construct a forwarding handler for a `FromCall` `Query`/`Mutation` leaf:
|
||||
/// on invocation, calls the remote op via the `CallConnection` and returns
|
||||
/// its `ResponseEnvelope` (single `call_with_payload()`, `HandlerKind::Once`).
|
||||
/// `Subscription` ops use [`make_streaming_forwarding_handler`] instead.
|
||||
///
|
||||
/// Per ADR-032 §3, the handler populates `forwarded_for` on the
|
||||
/// `call.requested` payload from the hub's `OperationContext.identity` (the
|
||||
@@ -323,12 +336,6 @@ fn parse_access_control(v: &Value) -> AccessControl {
|
||||
/// If `context.identity` is `None` (the hub chose not to disclose, or has not
|
||||
/// authenticated an originator), `forwarded_for` is omitted — the spoke
|
||||
/// receives only the hub's identity.
|
||||
///
|
||||
/// For a `Subscription` op, the handler calls `subscribe` and streams until
|
||||
/// `completed`/`aborted` (the streaming path is exercised at the
|
||||
/// `CallConnection` layer; the handler here forwards the first response for
|
||||
/// query/mutation and delegates streaming to the caller via the returned
|
||||
/// envelope).
|
||||
fn make_forwarding_handler(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
@@ -357,6 +364,40 @@ fn make_forwarding_handler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct a streaming forwarding handler for a `FromCall` `Subscription`
|
||||
/// leaf: on invocation, calls `CallConnection::subscribe_with_payload()` and
|
||||
/// forwards the remote stream end-to-end. Each `call.responded` from the
|
||||
/// remote becomes a stream item, `call.completed` ends the stream, and
|
||||
/// `call.aborted` drops it (ADR-049 §8). No truncation, no first-value
|
||||
/// fallback.
|
||||
///
|
||||
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3) and
|
||||
/// `auth_token` from the hub's own call-protocol token, exactly as the
|
||||
/// request/response forwarding handler does — both via `build_forwarded_payload`
|
||||
/// (no new payload-construction code). The `subscribe_with_payload` path
|
||||
/// registers the request in `PendingRequestMap`, so the abort cascade
|
||||
/// (ADR-016 §6) is already wired: a parent abort drops the
|
||||
/// `SubscriptionStream`, which sends `call.aborted` to the remote node.
|
||||
fn make_streaming_forwarding_handler(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
credentials_auth_token: Option<String>,
|
||||
) -> StreamingHandler {
|
||||
use crate::registry::registration::make_streaming_handler;
|
||||
use futures::stream::{once, StreamExt};
|
||||
make_streaming_handler(move |input, context| {
|
||||
let connection = Arc::clone(&connection);
|
||||
let remote_name = remote_name.clone();
|
||||
let auth_token = credentials_auth_token.clone();
|
||||
once(async move {
|
||||
let payload =
|
||||
build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
|
||||
connection.subscribe_with_payload(payload).await
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the `call.requested` payload for a forwarded call, populating
|
||||
/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3).
|
||||
/// `forwarded_for` is omitted when `context.identity` is `None` (the hub
|
||||
@@ -389,7 +430,7 @@ fn build_forwarded_payload(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::connection::CallConnection;
|
||||
use crate::registry::registration::make_handler;
|
||||
use crate::registry::registration::{make_handler, make_streaming_handler};
|
||||
use crate::registry::spec::OperationType;
|
||||
use alknet_core::auth::Identity;
|
||||
use alknet_core::types::{Capabilities, MockConnection};
|
||||
@@ -549,7 +590,7 @@ mod tests {
|
||||
);
|
||||
let reg = HandlerRegistration::new(
|
||||
spec,
|
||||
handler,
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -722,6 +763,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn op_summary_typed(name: &str, op_type: &str, conn: &CallConnection) -> OpSummary {
|
||||
OpSummary {
|
||||
name: name.to_string(),
|
||||
schema: sample_schema_json(name, op_type),
|
||||
connection: conn.clone(),
|
||||
credentials_auth_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bundles_same_peer_collision_returns_same_peer_collision_error() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
@@ -822,4 +872,234 @@ mod tests {
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.name, "worker/exec");
|
||||
}
|
||||
|
||||
// --- ADR-049 §8: streaming forwarding for Subscription ops -------------
|
||||
|
||||
#[test]
|
||||
fn build_bundles_subscription_op_produces_stream_kind() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let discovered = vec![op_summary_typed("events/stream", "subscription", &conn)];
|
||||
let bundles = build_bundles(discovered, &None, &None).expect("bundles");
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.op_type, OperationType::Subscription);
|
||||
assert!(
|
||||
matches!(bundles[0].handler, HandlerKind::Stream(_)),
|
||||
"Subscription op must register HandlerKind::Stream"
|
||||
);
|
||||
assert_eq!(bundles[0].provenance, OperationProvenance::FromCall);
|
||||
assert!(bundles[0].composition_authority.is_none());
|
||||
assert!(bundles[0].scoped_env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bundles_query_op_produces_once_kind() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let discovered = vec![op_summary_typed("fs/readFile", "query", &conn)];
|
||||
let bundles = build_bundles(discovered, &None, &None).expect("bundles");
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.op_type, OperationType::Query);
|
||||
assert!(
|
||||
matches!(bundles[0].handler, HandlerKind::Once(_)),
|
||||
"Query op must register HandlerKind::Once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bundles_mutation_op_produces_once_kind() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let discovered = vec![op_summary_typed("fs/writeFile", "mutation", &conn)];
|
||||
let bundles = build_bundles(discovered, &None, &None).expect("bundles");
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.op_type, OperationType::Mutation);
|
||||
assert!(
|
||||
matches!(bundles[0].handler, HandlerKind::Once(_)),
|
||||
"Mutation op must register HandlerKind::Once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bundles_mixed_op_types_route_to_correct_kind() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let discovered = vec![
|
||||
op_summary_typed("fs/readFile", "query", &conn),
|
||||
op_summary_typed("fs/writeFile", "mutation", &conn),
|
||||
op_summary_typed("events/stream", "subscription", &conn),
|
||||
];
|
||||
let bundles = build_bundles(discovered, &None, &None).expect("bundles");
|
||||
assert_eq!(bundles.len(), 3);
|
||||
let by_name: std::collections::HashMap<&str, &HandlerKind> = bundles
|
||||
.iter()
|
||||
.map(|b| (b.spec.name.as_str(), &b.handler))
|
||||
.collect();
|
||||
assert!(matches!(by_name["fs/readFile"], HandlerKind::Once(_)));
|
||||
assert!(matches!(by_name["fs/writeFile"], HandlerKind::Once(_)));
|
||||
assert!(matches!(by_name["events/stream"], HandlerKind::Stream(_)));
|
||||
}
|
||||
|
||||
/// Verify `make_streaming_forwarding_handler` produces a `StreamingHandler`
|
||||
/// that builds the forwarded payload with `forwarded_for` populated from
|
||||
/// `context.identity` (ADR-032) and calls `subscribe_with_payload`. Since
|
||||
/// `subscribe_with_payload` on a mock connection returns a closed stream
|
||||
/// (no transport), we capture the payload by intercepting the build step:
|
||||
/// the handler's contract is "build payload via `build_forwarded_payload`,
|
||||
/// then call `subscribe_with_payload(payload)`". We mirror the existing
|
||||
/// `forwarding_handler_populates_forwarded_for` test by constructing the
|
||||
/// handler and exercising the payload-construction path it relies on, plus
|
||||
/// asserting the produced stream terminates (the mock-connection path
|
||||
/// yields one error envelope then ends — no truncation, no hang).
|
||||
#[tokio::test]
|
||||
async fn streaming_forwarding_handler_populates_forwarded_for_and_streams() {
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let captured_payload = Arc::new(StdMutex::new(None::<Value>));
|
||||
let captured = Arc::clone(&captured_payload);
|
||||
|
||||
let handler: StreamingHandler = {
|
||||
let conn = Arc::clone(&conn);
|
||||
make_streaming_handler(move |input, context| {
|
||||
let conn = Arc::clone(&conn);
|
||||
let captured = Arc::clone(&captured);
|
||||
let remote_name = "events/stream".to_string();
|
||||
use futures::stream::{once, StreamExt};
|
||||
once(async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, None);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
conn.subscribe_with_payload(payload).await
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
|
||||
let ctx = test_context(Some(alice_identity()));
|
||||
let mut stream = handler(json!({}), ctx);
|
||||
let first = stream.next().await;
|
||||
assert!(
|
||||
first.is_some(),
|
||||
"streaming forwarding handler must produce at least one envelope"
|
||||
);
|
||||
if let Some(env) = first {
|
||||
assert!(
|
||||
env.result.is_err(),
|
||||
"mock connection has no transport, so the stream yields an error envelope"
|
||||
);
|
||||
}
|
||||
let second = stream.next().await;
|
||||
assert!(
|
||||
second.is_none(),
|
||||
"stream must terminate after the error (no truncation, no hang)"
|
||||
);
|
||||
|
||||
let payload = captured_payload.lock().unwrap().clone().expect("captured");
|
||||
assert_eq!(payload["operationId"], "events/stream");
|
||||
assert_eq!(payload["forwarded_for"]["id"], "alice");
|
||||
}
|
||||
|
||||
/// The streaming forwarding handler omits `forwarded_for` when
|
||||
/// `context.identity` is `None`, mirroring the request/response handler.
|
||||
#[tokio::test]
|
||||
async fn streaming_forwarding_handler_omits_forwarded_for_when_identity_none() {
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let captured_payload = Arc::new(StdMutex::new(None::<Value>));
|
||||
let captured = Arc::clone(&captured_payload);
|
||||
|
||||
let handler: StreamingHandler = {
|
||||
let conn = Arc::clone(&conn);
|
||||
make_streaming_handler(move |input, context| {
|
||||
let conn = Arc::clone(&conn);
|
||||
let captured = Arc::clone(&captured);
|
||||
let remote_name = "events/stream".to_string();
|
||||
use futures::stream::{once, StreamExt};
|
||||
once(async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, None);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
conn.subscribe_with_payload(payload).await
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
|
||||
let ctx = test_context(None);
|
||||
let mut stream = handler(json!({}), ctx);
|
||||
let _ = stream.next().await;
|
||||
let payload = captured_payload.lock().unwrap().clone().expect("captured");
|
||||
assert!(
|
||||
payload.get("forwarded_for").is_none(),
|
||||
"forwarded_for must be omitted when context.identity is None"
|
||||
);
|
||||
assert_eq!(payload["operationId"], "events/stream");
|
||||
}
|
||||
|
||||
/// The streaming forwarding handler populates `auth_token` when the hub's
|
||||
/// own call-protocol token is provided.
|
||||
#[tokio::test]
|
||||
async fn streaming_forwarding_handler_sets_auth_token_when_provided() {
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
let captured_payload = Arc::new(StdMutex::new(None::<Value>));
|
||||
let captured = Arc::clone(&captured_payload);
|
||||
|
||||
let handler: StreamingHandler = {
|
||||
let conn = Arc::clone(&conn);
|
||||
make_streaming_handler(move |input, context| {
|
||||
let conn = Arc::clone(&conn);
|
||||
let captured = Arc::clone(&captured);
|
||||
let remote_name = "events/stream".to_string();
|
||||
use futures::stream::{once, StreamExt};
|
||||
once(async move {
|
||||
let payload = build_forwarded_payload(
|
||||
&remote_name,
|
||||
input,
|
||||
&context,
|
||||
Some("alk_hub_token"),
|
||||
);
|
||||
*captured.lock().unwrap() = Some(payload.clone());
|
||||
conn.subscribe_with_payload(payload).await
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
|
||||
let ctx = test_context(Some(alice_identity()));
|
||||
let mut stream = handler(json!({}), ctx);
|
||||
let _ = stream.next().await;
|
||||
let payload = captured_payload.lock().unwrap().clone().expect("captured");
|
||||
assert_eq!(payload["auth_token"], "alk_hub_token");
|
||||
assert_eq!(payload["forwarded_for"]["id"], "alice");
|
||||
}
|
||||
|
||||
/// `make_streaming_forwarding_handler` produces a `StreamingHandler` (not a
|
||||
/// `Handler`) — verifies the helper returns the right type and that
|
||||
/// `build_bundles` wires it into `HandlerKind::Stream`.
|
||||
#[test]
|
||||
fn make_streaming_forwarding_handler_returns_streaming_handler() {
|
||||
let handler = make_streaming_forwarding_handler(
|
||||
Arc::new(CallConnection::new(stub_connection())),
|
||||
"events/stream".to_string(),
|
||||
None,
|
||||
);
|
||||
let reg = HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Subscription,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
HandlerKind::Stream(handler),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
);
|
||||
assert!(matches!(reg.handler, HandlerKind::Stream(_)));
|
||||
assert_eq!(reg.provenance, OperationProvenance::FromCall);
|
||||
assert!(reg.composition_authority.is_none());
|
||||
assert!(reg.scoped_env.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use serde_json::Value;
|
||||
use crate::client::{AdapterError, OperationAdapter};
|
||||
use crate::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use crate::registry::context::OperationContext;
|
||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use crate::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use crate::registry::spec::OperationSpec;
|
||||
|
||||
/// Build a [`HandlerRegistration`] from a JSON Schema-described operation.
|
||||
@@ -30,7 +32,7 @@ pub fn from_jsonschema(spec: OperationSpec, _schema: Value) -> HandlerRegistrati
|
||||
});
|
||||
HandlerRegistration::new(
|
||||
spec,
|
||||
handler,
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::FromJsonSchema,
|
||||
None,
|
||||
None,
|
||||
@@ -138,7 +140,10 @@ mod tests {
|
||||
async fn placeholder_handler_returns_error_when_invoked() {
|
||||
let bundle = from_jsonschema_fn::from_jsonschema(test_spec("ns/op"), serde_json::json!({}));
|
||||
let ctx = test_context("req-1");
|
||||
let response = (bundle.handler)(serde_json::json!({}), ctx).await;
|
||||
let response = match &bundle.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "NOT_FOUND");
|
||||
|
||||
@@ -166,7 +166,9 @@ mod tests {
|
||||
};
|
||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
use crate::registry::env::OperationEnv;
|
||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use crate::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::AuthToken;
|
||||
use alknet_core::types::Capabilities;
|
||||
@@ -245,22 +247,24 @@ mod tests {
|
||||
handler: crate::registry::registration::Handler,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -421,14 +425,16 @@ mod tests {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
|
||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("agent/run", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
Some(scoped.clone()),
|
||||
caps.clone(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("agent/run", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
Some(scoped.clone()),
|
||||
caps.clone(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let adapter = CallAdapter::new(registry, provider);
|
||||
@@ -543,7 +549,7 @@ mod tests {
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -610,7 +616,7 @@ mod tests {
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -26,7 +26,7 @@ use super::wire::{
|
||||
use crate::protocol::wire::ResponseEnvelope;
|
||||
use crate::registry::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
use crate::registry::env::OperationEnv;
|
||||
use crate::registry::registration::{Handler, HandlerRegistration};
|
||||
use crate::registry::registration::{HandlerKind, HandlerRegistration};
|
||||
use crate::registry::spec::AccessResult;
|
||||
|
||||
const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -168,11 +168,26 @@ impl CallConnection {
|
||||
operation_id: &str,
|
||||
input: Value,
|
||||
) -> impl Stream<Item = ResponseEnvelope> {
|
||||
let request_id = generate_request_id();
|
||||
let payload = serde_json::json!({
|
||||
"operationId": operation_id,
|
||||
"input": input,
|
||||
});
|
||||
self.subscribe_with_payload(payload).await
|
||||
}
|
||||
|
||||
/// Subscribe to a remote op with a caller-constructed `call.requested`
|
||||
/// payload. The payload MUST include `operationId` and `input`; the
|
||||
/// caller may add `forwarded_for` (ADR-032) and `auth_token` (ADR-017 §7)
|
||||
/// for the hub forwarding path used by `from_call`'s streaming forwarding
|
||||
/// handler. Mirrors [`call_with_payload`](Self::call_with_payload) so the
|
||||
/// forwarding handler can populate `forwarded_for` + `auth_token` on the
|
||||
/// subscription payload (the plain [`subscribe`](Self::subscribe) builds
|
||||
/// the payload internally and omits those fields).
|
||||
pub async fn subscribe_with_payload(
|
||||
&self,
|
||||
payload: Value,
|
||||
) -> impl Stream<Item = ResponseEnvelope> {
|
||||
let request_id = generate_request_id();
|
||||
|
||||
let connection = match &self.connection {
|
||||
Some(c) => c,
|
||||
@@ -307,7 +322,7 @@ impl OperationEnv for OverlayOperationEnv {
|
||||
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
||||
}
|
||||
|
||||
let handler: Handler;
|
||||
let handler: HandlerKind;
|
||||
let composition_authority;
|
||||
let scoped_env;
|
||||
let access_control;
|
||||
@@ -316,7 +331,7 @@ impl OperationEnv for OverlayOperationEnv {
|
||||
let Some(registration) = overlay.get(&name) else {
|
||||
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
||||
};
|
||||
handler = Arc::clone(®istration.handler);
|
||||
handler = registration.handler.clone();
|
||||
composition_authority = registration.composition_authority.clone();
|
||||
scoped_env = registration
|
||||
.scoped_env
|
||||
@@ -355,7 +370,15 @@ impl OperationEnv for OverlayOperationEnv {
|
||||
internal: true,
|
||||
};
|
||||
|
||||
handler(input, context).await
|
||||
match handler {
|
||||
HandlerKind::Once(h) => h(input, context).await,
|
||||
HandlerKind::Stream(_) => ResponseEnvelope::error(
|
||||
parent.request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"OperationEnv::invoke() called on a Subscription op; composition is request/response-only",
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, name: &str) -> bool {
|
||||
@@ -421,7 +444,7 @@ impl Stream for SubscriptionStream {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::context::CompositionAuthority;
|
||||
use crate::registry::registration::{make_handler, OperationProvenance};
|
||||
use crate::registry::registration::{make_handler, Handler, HandlerKind, OperationProvenance};
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::types::{Capabilities, MockConnection};
|
||||
use std::collections::HashMap;
|
||||
@@ -476,7 +499,7 @@ mod tests {
|
||||
fn imported_registration(name: &str) -> HandlerRegistration {
|
||||
HandlerRegistration::new(
|
||||
external_spec(name),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -608,7 +631,7 @@ mod tests {
|
||||
});
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
external_spec("worker/exec"),
|
||||
inspect_handler,
|
||||
HandlerKind::Once(inspect_handler),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -631,7 +654,9 @@ mod tests {
|
||||
fn connection_accessor_returns_underlying_connection() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
assert_eq!(
|
||||
conn.connection().expect("quic connection present").remote_alpn(),
|
||||
conn.connection()
|
||||
.expect("quic connection present")
|
||||
.remote_alpn(),
|
||||
b"alknet/call"
|
||||
);
|
||||
}
|
||||
@@ -960,4 +985,39 @@ mod tests {
|
||||
assert!(conn.connection().is_some(), "QUIC connection present");
|
||||
assert!(conn.identity().is_none(), "no identity set yet");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlay_env_invoke_on_stream_kind_returns_invalid_operation_type() {
|
||||
use crate::registry::registration::make_streaming_handler;
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let streaming_handler = make_streaming_handler(|input, ctx| {
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
|
||||
});
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Subscription,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
HandlerKind::Stream(streaming_handler),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
let env = conn.overlay_env();
|
||||
let scoped = ScopedPeerEnv::new(["events/stream"]);
|
||||
let ctx = root_context("root-stream", scoped, env.clone());
|
||||
let response = env
|
||||
.invoke("events", "stream", serde_json::json!({}), &ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
|
||||
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,9 @@ impl Clone for Dispatcher {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::wire::EVENT_RESPONDED;
|
||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use crate::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use alknet_core::types::{Capabilities, MockConnection};
|
||||
@@ -412,24 +414,26 @@ mod tests {
|
||||
|
||||
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -451,14 +455,16 @@ mod tests {
|
||||
serde_json::json!({ "has_google": has_google }),
|
||||
)
|
||||
});
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("admin/run", AccessControl::default()),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
caps,
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("admin/run", AccessControl::default()),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
caps,
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
@@ -486,20 +492,22 @@ mod tests {
|
||||
serde_json::json!({ "has_google": has_google }),
|
||||
)
|
||||
});
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/run",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
caps,
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/run",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
caps,
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
||||
StaticIdentityProvider::new()
|
||||
@@ -609,14 +617,16 @@ mod tests {
|
||||
serde_json::json!({ "forwarded_for_id": forwarded_id }),
|
||||
)
|
||||
});
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
@@ -648,14 +658,16 @@ mod tests {
|
||||
serde_json::json!({ "present": present }),
|
||||
)
|
||||
});
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
@@ -736,14 +748,16 @@ mod tests {
|
||||
serde_json::json!({ "peer_ids": peer_ids }),
|
||||
)
|
||||
});
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
@@ -795,7 +809,11 @@ mod tests {
|
||||
let child_id = "ws-abort-child".to_string();
|
||||
{
|
||||
let mut pending = conn.pending().lock();
|
||||
pending.register_call(parent_id.clone(), Instant::now() + Duration::from_secs(30), None);
|
||||
pending.register_call(
|
||||
parent_id.clone(),
|
||||
Instant::now() + Duration::from_secs(30),
|
||||
None,
|
||||
);
|
||||
pending.register_call(
|
||||
child_id.clone(),
|
||||
Instant::now() + Duration::from_secs(30),
|
||||
@@ -844,11 +862,16 @@ mod tests {
|
||||
"input": { "v": 42 },
|
||||
});
|
||||
let request_id = "ws-roundtrip-1".to_string();
|
||||
let response = dp.dispatch_requested(&conn, request_id.clone(), payload).await;
|
||||
let response = dp
|
||||
.dispatch_requested(&conn, request_id.clone(), payload)
|
||||
.await;
|
||||
assert!(response.result.is_ok());
|
||||
let envelope: EventEnvelope = response.into();
|
||||
assert_eq!(envelope.r#type, EVENT_RESPONDED);
|
||||
assert_eq!(envelope.id, "ws-roundtrip-1");
|
||||
assert_eq!(envelope.payload.get("output"), Some(&serde_json::json!({ "v": 42 })));
|
||||
assert_eq!(
|
||||
envelope.payload.get("output"),
|
||||
Some(&serde_json::json!({ "v": 42 }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ impl CallError {
|
||||
pub fn timeout(message: impl Into<String>) -> Self {
|
||||
Self::new("TIMEOUT", message, true)
|
||||
}
|
||||
|
||||
pub fn invalid_operation_type(message: impl Into<String>) -> Self {
|
||||
Self::new("INVALID_OPERATION_TYPE", message, false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for CallError {}
|
||||
|
||||
@@ -324,7 +324,10 @@ pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::context::{CompositionAuthority, ScopedPeerEnv};
|
||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use crate::registry::registration::{
|
||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
|
||||
OperationProvenance, StreamingHandler,
|
||||
};
|
||||
use alknet_core::types::Capabilities;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
@@ -359,6 +362,12 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_streaming_handler() -> StreamingHandler {
|
||||
make_streaming_handler(|input, context| {
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
|
||||
})
|
||||
}
|
||||
|
||||
fn noop_env() -> Arc<dyn crate::registry::env::OperationEnv + Send + Sync> {
|
||||
struct NoopEnv;
|
||||
#[async_trait::async_trait]
|
||||
@@ -439,36 +448,42 @@ mod tests {
|
||||
|
||||
fn registry_with_access_controlled_ops() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec_with_acl("public/echo", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec_with_acl(
|
||||
"admin/secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("internal/hidden"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec_with_acl("public/echo", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec_with_acl(
|
||||
"admin/secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("internal/hidden"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -485,59 +500,67 @@ mod tests {
|
||||
|
||||
fn registry_with_ops() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret/internal"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/subscribe",
|
||||
OperationType::Subscription,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"fs/readFileErr",
|
||||
OperationType::Query,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![super::super::spec::ErrorDefinition {
|
||||
code: "FILE_NOT_FOUND".to_string(),
|
||||
description: "file not found".to_string(),
|
||||
schema: json!({ "type": "object" }),
|
||||
http_status: None,
|
||||
}],
|
||||
AccessControl::default(),
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("secret/internal"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/subscribe",
|
||||
OperationType::Subscription,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
HandlerKind::Stream(echo_streaming_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"fs/readFileErr",
|
||||
OperationType::Query,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![super::super::spec::ErrorDefinition {
|
||||
code: "FILE_NOT_FOUND".to_string(),
|
||||
description: "file not found".to_string(),
|
||||
schema: json!({ "type": "object" }),
|
||||
http_status: None,
|
||||
}],
|
||||
AccessControl::default(),
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -669,22 +692,26 @@ mod tests {
|
||||
let schema_handler = services_schema_handler(Arc::clone(®istry));
|
||||
|
||||
let mut discovery_registry = OperationRegistry::new();
|
||||
discovery_registry.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
list_handler,
|
||||
OperationProvenance::Local,
|
||||
CompositionAuthority::none(),
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
discovery_registry.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
schema_handler,
|
||||
OperationProvenance::Local,
|
||||
CompositionAuthority::none(),
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
discovery_registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
HandlerKind::Once(list_handler),
|
||||
OperationProvenance::Local,
|
||||
CompositionAuthority::none(),
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
discovery_registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
HandlerKind::Once(schema_handler),
|
||||
OperationProvenance::Local,
|
||||
CompositionAuthority::none(),
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let discovery = Arc::new(discovery_registry);
|
||||
|
||||
let ctx = root_context("req-6");
|
||||
|
||||
@@ -303,7 +303,9 @@ impl OperationEnv for PeerCompositeEnv {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::context::CompositionAuthority;
|
||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use crate::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::Identity;
|
||||
use alknet_core::types::Capabilities;
|
||||
@@ -406,22 +408,24 @@ mod tests {
|
||||
scoped_env: Option<ScopedPeerEnv>,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
spec_visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
composition_authority,
|
||||
scoped_env,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
spec_visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::Local,
|
||||
composition_authority,
|
||||
scoped_env,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alknet_core::types::Capabilities;
|
||||
use futures::stream::Stream;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::context::{CompositionAuthority, OperationContext, ScopedPeerEnv};
|
||||
use super::spec::{AccessResult, OperationSpec, Visibility};
|
||||
use crate::protocol::wire::ResponseEnvelope;
|
||||
use super::spec::{AccessResult, OperationSpec, OperationType, Visibility};
|
||||
use crate::protocol::wire::{CallError, ResponseEnvelope};
|
||||
|
||||
pub type Handler = Arc<
|
||||
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
@@ -16,6 +17,20 @@ pub type Handler = Arc<
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
pub type StreamingHandler = Arc<
|
||||
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OperationProvenance {
|
||||
Local,
|
||||
@@ -28,7 +43,7 @@ pub enum OperationProvenance {
|
||||
|
||||
pub struct HandlerRegistration {
|
||||
pub spec: OperationSpec,
|
||||
pub handler: Handler,
|
||||
pub handler: HandlerKind,
|
||||
pub provenance: OperationProvenance,
|
||||
pub composition_authority: Option<CompositionAuthority>,
|
||||
pub scoped_env: Option<ScopedPeerEnv>,
|
||||
@@ -38,7 +53,7 @@ pub struct HandlerRegistration {
|
||||
impl HandlerRegistration {
|
||||
pub fn new(
|
||||
spec: OperationSpec,
|
||||
handler: Handler,
|
||||
handler: HandlerKind,
|
||||
provenance: OperationProvenance,
|
||||
composition_authority: Option<CompositionAuthority>,
|
||||
scoped_env: Option<ScopedPeerEnv>,
|
||||
@@ -66,9 +81,24 @@ impl OperationRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, registration: HandlerRegistration) {
|
||||
pub fn register(&mut self, registration: HandlerRegistration) -> Result<(), String> {
|
||||
let expected = match registration.spec.op_type {
|
||||
OperationType::Query | OperationType::Mutation => "Once",
|
||||
OperationType::Subscription => "Stream",
|
||||
};
|
||||
let actual = match registration.handler {
|
||||
HandlerKind::Once(_) => "Once",
|
||||
HandlerKind::Stream(_) => "Stream",
|
||||
};
|
||||
if expected != actual {
|
||||
return Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::{} (got HandlerKind::{})",
|
||||
registration.spec.op_type, expected, actual
|
||||
));
|
||||
}
|
||||
self.operations
|
||||
.insert(registration.spec.name.clone(), registration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn registration(&self, name: &str) -> Option<&HandlerRegistration> {
|
||||
@@ -113,8 +143,18 @@ impl OperationRegistry {
|
||||
return ResponseEnvelope::forbidden(request_id, message);
|
||||
}
|
||||
|
||||
let handler = Arc::clone(®istration.handler);
|
||||
(handler)(input, context).await
|
||||
match ®istration.handler {
|
||||
HandlerKind::Once(handler) => {
|
||||
let handler = Arc::clone(handler);
|
||||
(handler)(input, context).await
|
||||
}
|
||||
HandlerKind::Stream(_) => ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"invoke() called on a Subscription op; use invoke_streaming()",
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +175,30 @@ impl OperationRegistryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn store(mut self, registration: HandlerRegistration) -> Self {
|
||||
self.operations
|
||||
.insert(registration.spec.name.clone(), registration);
|
||||
self
|
||||
fn store(mut self, registration: HandlerRegistration) -> Result<Self, String> {
|
||||
let name = registration.spec.name.clone();
|
||||
self.operations.insert(name, registration);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn wrap_once(spec: &OperationSpec, handler: Handler) -> Result<HandlerKind, String> {
|
||||
match spec.op_type {
|
||||
OperationType::Query | OperationType::Mutation => Ok(HandlerKind::Once(handler)),
|
||||
OperationType::Subscription => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Stream (got Handler)",
|
||||
spec.op_type
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_stream(spec: &OperationSpec, handler: StreamingHandler) -> Result<HandlerKind, String> {
|
||||
match spec.op_type {
|
||||
OperationType::Subscription => Ok(HandlerKind::Stream(handler)),
|
||||
OperationType::Query | OperationType::Mutation => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Once (got StreamingHandler)",
|
||||
spec.op_type
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_local(
|
||||
@@ -148,10 +208,31 @@ impl OperationRegistryBuilder {
|
||||
composition_authority: Option<CompositionAuthority>,
|
||||
scoped_env: Option<ScopedPeerEnv>,
|
||||
capabilities: Capabilities,
|
||||
) -> Self {
|
||||
) -> Result<Self, String> {
|
||||
let kind = Self::wrap_once(&spec, handler)?;
|
||||
let registration = HandlerRegistration::new(
|
||||
spec,
|
||||
handler,
|
||||
kind,
|
||||
OperationProvenance::Local,
|
||||
composition_authority,
|
||||
scoped_env,
|
||||
capabilities,
|
||||
);
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
pub fn with_local_streaming(
|
||||
self,
|
||||
spec: OperationSpec,
|
||||
handler: StreamingHandler,
|
||||
composition_authority: Option<CompositionAuthority>,
|
||||
scoped_env: Option<ScopedPeerEnv>,
|
||||
capabilities: Capabilities,
|
||||
) -> Result<Self, String> {
|
||||
let kind = Self::wrap_stream(&spec, handler)?;
|
||||
let registration = HandlerRegistration::new(
|
||||
spec,
|
||||
kind,
|
||||
OperationProvenance::Local,
|
||||
composition_authority,
|
||||
scoped_env,
|
||||
@@ -165,7 +246,7 @@ impl OperationRegistryBuilder {
|
||||
spec: OperationSpec,
|
||||
handler: Handler,
|
||||
capabilities: Capabilities,
|
||||
) -> Self {
|
||||
) -> Result<Self, String> {
|
||||
self.with_leaf_provenance(
|
||||
spec,
|
||||
handler,
|
||||
@@ -180,13 +261,41 @@ impl OperationRegistryBuilder {
|
||||
handler: Handler,
|
||||
provenance: OperationProvenance,
|
||||
capabilities: Capabilities,
|
||||
) -> Self {
|
||||
) -> Result<Self, String> {
|
||||
let kind = Self::wrap_once(&spec, handler)?;
|
||||
let registration =
|
||||
HandlerRegistration::new(spec, handler, provenance, None, None, capabilities);
|
||||
HandlerRegistration::new(spec, kind, provenance, None, None, capabilities);
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
pub fn with(self, registration: HandlerRegistration) -> Self {
|
||||
pub fn with_leaf_streaming(
|
||||
self,
|
||||
spec: OperationSpec,
|
||||
handler: StreamingHandler,
|
||||
capabilities: Capabilities,
|
||||
) -> Result<Self, String> {
|
||||
self.with_leaf_streaming_provenance(
|
||||
spec,
|
||||
handler,
|
||||
OperationProvenance::FromOpenAPI,
|
||||
capabilities,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_leaf_streaming_provenance(
|
||||
self,
|
||||
spec: OperationSpec,
|
||||
handler: StreamingHandler,
|
||||
provenance: OperationProvenance,
|
||||
capabilities: Capabilities,
|
||||
) -> Result<Self, String> {
|
||||
let kind = Self::wrap_stream(&spec, handler)?;
|
||||
let registration =
|
||||
HandlerRegistration::new(spec, kind, provenance, None, None, capabilities);
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
pub fn with(self, registration: HandlerRegistration) -> Result<Self, String> {
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
@@ -211,6 +320,14 @@ where
|
||||
Arc::new(move |input, context| Box::pin(f(input, context)))
|
||||
}
|
||||
|
||||
pub fn make_streaming_handler<S, St>(f: S) -> StreamingHandler
|
||||
where
|
||||
S: Fn(Value, OperationContext) -> St + Send + Sync + 'static,
|
||||
St: Stream<Item = ResponseEnvelope> + Send + 'static,
|
||||
{
|
||||
Arc::new(move |input, context| Box::pin(f(input, context)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -312,14 +429,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn register_and_invoke_simple_operation() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-1", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry
|
||||
.invoke("echo", serde_json::json!({"hi": 1}), ctx)
|
||||
@@ -331,14 +450,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn internal_op_from_external_call_returns_not_found() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("secret", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-2", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry.invoke("secret", serde_json::json!({}), ctx).await;
|
||||
match response.result {
|
||||
@@ -353,14 +474,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn internal_op_from_internal_call_invokes_handler() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("secret", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-3", None, None, true, ScopedPeerEnv::empty());
|
||||
let response = registry
|
||||
.invoke("secret", serde_json::json!({"x": 2}), ctx)
|
||||
@@ -383,20 +506,22 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn acl_sufficient_scopes_allowed() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-5",
|
||||
Some(identity_with_scopes("caller", &["admin"])),
|
||||
@@ -411,20 +536,22 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn acl_insufficient_scopes_forbidden() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-6",
|
||||
Some(identity_with_scopes("caller", &["user"])),
|
||||
@@ -445,20 +572,22 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn acl_restricted_op_no_identity_forbidden() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-7", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry.invoke("admin", serde_json::json!({}), ctx).await;
|
||||
match response.result {
|
||||
@@ -474,20 +603,22 @@ mod tests {
|
||||
async fn internal_call_acl_uses_handler_identity() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let composing_authority = CompositionAuthority::new("agent-chat", ["admin".to_string()]);
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec(
|
||||
"secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec(
|
||||
"secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-8",
|
||||
Some(identity_with_scopes("user", &["user"])),
|
||||
@@ -506,20 +637,22 @@ mod tests {
|
||||
async fn internal_call_acl_insufficient_handler_identity_forbidden() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let weak_authority = CompositionAuthority::new("weak", ["user".to_string()]);
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec(
|
||||
"secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec(
|
||||
"secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-9",
|
||||
Some(identity_with_scopes("user", &["admin"])),
|
||||
@@ -541,20 +674,22 @@ mod tests {
|
||||
async fn external_call_acl_uses_caller_identity_not_handler_identity() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let handler_authority = CompositionAuthority::new("agent", ["admin".to_string()]);
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"gate",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
Some(handler_authority),
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"gate",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
Some(handler_authority),
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-10",
|
||||
Some(identity_with_scopes("user", &["user"])),
|
||||
@@ -572,22 +707,26 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn list_operations_returns_external_only() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("secret", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ops = registry.list_operations();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].name, "echo");
|
||||
@@ -596,14 +735,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn handler_returned_error_passes_through() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("boom", AccessControl::default()),
|
||||
error_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("boom", AccessControl::default()),
|
||||
HandlerKind::Once(error_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-11", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry.invoke("boom", serde_json::json!({}), ctx).await;
|
||||
match response.result {
|
||||
@@ -622,6 +763,7 @@ mod tests {
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("echo").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::Local);
|
||||
@@ -639,6 +781,7 @@ mod tests {
|
||||
Some(ScopedPeerEnv::new(["fs/readFile"])),
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("agent").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::Local);
|
||||
@@ -657,6 +800,7 @@ mod tests {
|
||||
echo_handler(),
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("vastai").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::FromOpenAPI);
|
||||
@@ -673,6 +817,7 @@ mod tests {
|
||||
OperationProvenance::FromCall,
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("remote").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::FromCall);
|
||||
@@ -684,13 +829,16 @@ mod tests {
|
||||
fn builder_with_takes_full_bundle() {
|
||||
let registration = HandlerRegistration::new(
|
||||
external_spec("agent", AccessControl::default()),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Session,
|
||||
Some(CompositionAuthority::new("sandbox", [])),
|
||||
Some(ScopedPeerEnv::new(["fs/readFile"])),
|
||||
Capabilities::new(),
|
||||
);
|
||||
let registry = OperationRegistryBuilder::new().with(registration).build();
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with(registration)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("agent").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::Session);
|
||||
assert!(reg.composition_authority.is_some());
|
||||
@@ -717,18 +865,145 @@ mod tests {
|
||||
let authority = CompositionAuthority::new("agent", ["fs:read".to_string()]);
|
||||
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
|
||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("agent", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
Some(authority.clone()),
|
||||
Some(scoped.clone()),
|
||||
caps.clone(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("agent", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
Some(authority.clone()),
|
||||
Some(scoped.clone()),
|
||||
caps.clone(),
|
||||
))
|
||||
.unwrap();
|
||||
let reg = registry.registration("agent").expect("found");
|
||||
assert_eq!(reg.spec.name, "agent");
|
||||
assert_eq!(reg.provenance, OperationProvenance::Local);
|
||||
assert_eq!(reg.composition_authority.as_ref().unwrap().label, "agent");
|
||||
assert!(reg.scoped_env.as_ref().unwrap().allows("fs/readFile"));
|
||||
}
|
||||
|
||||
fn subscription_spec(name: &str) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Subscription,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_streaming_handler() -> StreamingHandler {
|
||||
make_streaming_handler(|input, context| {
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_on_stream_kind_returns_invalid_operation_type() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
HandlerKind::Stream(echo_streaming_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-iot", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry
|
||||
.invoke("events/stream", serde_json::json!({}), ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
|
||||
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_on_once_kind_dispatches_normally() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-once", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry
|
||||
.invoke("echo", serde_json::json!({"hi": 1}), ctx)
|
||||
.await;
|
||||
assert_eq!(response.result, Ok(serde_json::json!({"hi": 1})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_rejects_once_for_subscription_spec() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
msg.contains("Subscription")
|
||||
&& msg.contains("HandlerKind::Stream")
|
||||
&& msg.contains("HandlerKind::Once"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_rejects_stream_for_query_spec() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
HandlerKind::Stream(echo_streaming_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
(msg.contains("Query") || msg.contains("Mutation"))
|
||||
&& msg.contains("HandlerKind::Once")
|
||||
&& msg.contains("HandlerKind::Stream"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn make_streaming_handler_produces_working_stream() {
|
||||
use futures::stream::StreamExt;
|
||||
let handler = echo_streaming_handler();
|
||||
let ctx = root_context("req-st", None, None, false, ScopedPeerEnv::empty());
|
||||
let mut stream = handler(serde_json::json!({"v": 1}), ctx);
|
||||
let first = stream.next().await.expect("one envelope");
|
||||
assert_eq!(first.result, Ok(serde_json::json!({"v": 1})));
|
||||
let second = stream.next().await;
|
||||
assert!(second.is_none(), "stream ends after one value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_error_invalid_operation_type_is_not_retryable() {
|
||||
let err = CallError::invalid_operation_type("bad path");
|
||||
assert_eq!(err.code, "INVALID_OPERATION_TYPE");
|
||||
assert!(!err.retryable);
|
||||
assert!(err.details.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use alknet_call::registry::discovery::{
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, Handler, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{Identity, IdentityProvider};
|
||||
@@ -124,58 +124,66 @@ async fn build_raw_quinn_server(
|
||||
/// services/list + services/schema discovery handlers.
|
||||
fn build_server_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
))
|
||||
.unwrap();
|
||||
let discovery_registry = Arc::new(registry);
|
||||
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
|
||||
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
|
||||
let mut full = OperationRegistry::new();
|
||||
full.register(HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
))
|
||||
.unwrap();
|
||||
full.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
));
|
||||
))
|
||||
.unwrap();
|
||||
full.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
list_handler,
|
||||
HandlerKind::Once(list_handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
))
|
||||
.unwrap();
|
||||
full.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
schema_handler,
|
||||
HandlerKind::Once(schema_handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(full)
|
||||
}
|
||||
|
||||
@@ -191,14 +199,16 @@ async fn two_node_call_round_trip() {
|
||||
// it as UnknownIssuer since the self-signed cert is not in the platform
|
||||
// root store.
|
||||
let mut client_registry = OperationRegistry::new();
|
||||
client_registry.register(HandlerRegistration::new(
|
||||
external_spec("client/echo"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
client_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("client/echo"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let client_registry = Arc::new(client_registry);
|
||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
use alknet_call::client::{AdapterError, OperationAdapter};
|
||||
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use alknet_call::registry::context::OperationContext;
|
||||
use alknet_call::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alknet_call::registry::spec::{
|
||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
@@ -156,7 +158,7 @@ fn build_registration(
|
||||
|
||||
HandlerRegistration::new(
|
||||
spec,
|
||||
handler,
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::FromMCP,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -17,7 +17,9 @@ use std::sync::Arc;
|
||||
use alknet_call::client::{AdapterError, OperationAdapter};
|
||||
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use alknet_call::registry::context::OperationContext;
|
||||
use alknet_call::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alknet_call::registry::spec::{
|
||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
@@ -469,7 +471,7 @@ impl FromOpenAPI {
|
||||
let capabilities = Capabilities::new();
|
||||
Ok(HandlerRegistration::new(
|
||||
spec,
|
||||
handler,
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::FromOpenAPI,
|
||||
None,
|
||||
None,
|
||||
@@ -1151,7 +1153,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let ctx = noop_context("req-10", Capabilities::new());
|
||||
let response = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
assert_eq!(response.request_id, "req-10");
|
||||
match response.result {
|
||||
Ok(v) => assert_eq!(v, serde_json::json!({"ok":true})),
|
||||
@@ -1176,7 +1181,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let ctx = noop_context("req-11", Capabilities::new());
|
||||
let response = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "HTTP_404");
|
||||
@@ -1201,7 +1209,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let ctx = noop_context("req-12", Capabilities::new());
|
||||
let response = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
assert!(response.result.is_ok());
|
||||
let last = response.result.unwrap();
|
||||
assert_eq!(last, serde_json::json!({"n":2}));
|
||||
@@ -1447,11 +1458,16 @@ mod tests {
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let ctx = noop_context("req-16", Capabilities::new());
|
||||
let response = (registration.handler)(
|
||||
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => {
|
||||
h(
|
||||
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"expected Ok, got {:?}",
|
||||
@@ -1483,7 +1499,10 @@ mod tests {
|
||||
let registration = &bundles[0];
|
||||
let caps = Capabilities::new().with_http_token("openai", "sk-test-token".to_string());
|
||||
let ctx = noop_context("req-17", caps);
|
||||
let _ = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let _ = match ®istration.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
let captured = rx.await.unwrap();
|
||||
assert_eq!(
|
||||
captured.headers.get("authorization").unwrap(),
|
||||
@@ -1519,7 +1538,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let ctx = noop_context("req-18", Capabilities::new());
|
||||
let response = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
match response.result {
|
||||
Ok(Value::String(s)) => assert_eq!(s, "hello world"),
|
||||
other => panic!("expected String, got {other:?}"),
|
||||
@@ -1540,7 +1562,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let ctx = noop_context("req-19", Capabilities::new());
|
||||
let response = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "HTTP_500"),
|
||||
other => panic!("expected HTTP_500, got {other:?}"),
|
||||
|
||||
@@ -16,7 +16,10 @@ pub mod from_mcp;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod to_mcp;
|
||||
|
||||
pub mod to_openapi;
|
||||
|
||||
pub use from_openapi::{FromOpenAPI, HttpAuthScheme, HttpServiceConfig, OpenAPISpec};
|
||||
pub use to_openapi::to_openapi;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub use from_mcp::FromMCP;
|
||||
|
||||
@@ -432,7 +432,7 @@ mod tests {
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
@@ -502,44 +502,52 @@ mod tests {
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut inner = OperationRegistry::new();
|
||||
for (name, op_type, acl) in specs {
|
||||
inner.register(HandlerRegistration::new(
|
||||
external_spec(&name, op_type, acl),
|
||||
make_echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
inner
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(&name, op_type, acl),
|
||||
HandlerKind::Once(make_echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let inner = Arc::new(inner);
|
||||
|
||||
let mut dispatch_registry = OperationRegistry::new();
|
||||
for op in inner.list_operations() {
|
||||
dispatch_registry.register(HandlerRegistration::new(
|
||||
external_spec(&op.name, op.op_type, op.access_control.clone()),
|
||||
make_echo_handler(),
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(&op.name, op.op_type, op.access_control.clone()),
|
||||
HandlerKind::Once(make_echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
}
|
||||
dispatch_registry.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
services_list_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
dispatch_registry.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
services_schema_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
))
|
||||
.unwrap();
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(dispatch_registry)
|
||||
}
|
||||
|
||||
|
||||
1056
crates/alknet-http/src/adapters/to_openapi.rs
Normal file
1056
crates/alknet-http/src/adapters/to_openapi.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ mod tests {
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerRegistration, OperationProvenance,
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::AuthToken;
|
||||
@@ -187,45 +187,51 @@ mod tests {
|
||||
|
||||
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> OperationRegistry {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
services_list_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
services_schema_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -270,32 +276,36 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn invoke_for_services_list_returns_access_control_filtered_list() {
|
||||
let mut inner = OperationRegistry::new();
|
||||
inner.register(HandlerRegistration::new(
|
||||
external_spec("public/echo", AccessControl::default()),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
inner.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
inner
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("public/echo", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
inner
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/secret",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let inner = Arc::new(inner);
|
||||
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
@@ -327,16 +337,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn invoke_for_services_schema_returns_spec_for_known_op() {
|
||||
let mut inner = OperationRegistry::new();
|
||||
inner.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
inner
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("fs/readFile", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let inner = Arc::new(inner);
|
||||
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
@@ -373,16 +385,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn invoke_for_internal_op_returns_not_found_not_leaked() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret/op", AccessControl::default()),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("secret/op", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = dispatch(registry, provider);
|
||||
@@ -499,16 +513,18 @@ mod tests {
|
||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("agent/run", AccessControl::default()),
|
||||
make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
}),
|
||||
OperationProvenance::Local,
|
||||
Some(authority),
|
||||
Some(scoped.clone()),
|
||||
caps,
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("agent/run", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
Some(authority),
|
||||
Some(scoped.clone()),
|
||||
caps,
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = dispatch(registry, provider);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! custom routes + decoy fallback) and drives hyper's HTTP/1.1 or HTTP/2
|
||||
//! connection driver over a single QUIC bidirectional stream. The 5 gateway
|
||||
//! endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are wired in
|
||||
//! from `gateway_routes`; `/openapi.json`, the MCP route, and the WS upgrade
|
||||
//! handler remain placeholder 501 handlers pending their respective tasks.
|
||||
//! from `gateway_routes`; `/openapi.json` serves the `to_openapi` projection
|
||||
//! of the registry.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
@@ -14,6 +14,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::response::IntoResponse;
|
||||
@@ -35,6 +36,7 @@ use super::gateway_routes;
|
||||
use super::healthz::healthz;
|
||||
#[cfg(feature = "mcp")]
|
||||
use crate::adapters::to_mcp_service;
|
||||
use crate::adapters::to_openapi;
|
||||
#[cfg(feature = "mcp")]
|
||||
use crate::gateway::GatewayDispatch;
|
||||
use crate::websocket::upgrade::ws_upgrade_handler;
|
||||
@@ -183,7 +185,7 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
|
||||
let default: Router<RouterState> = Router::new()
|
||||
.merge(gateway_routes::gateway_router())
|
||||
.route("/openapi.json", get(not_implemented))
|
||||
.route("/openapi.json", get(openapi_json_handler))
|
||||
.route(WS_UPGRADE_PATH, get(ws_upgrade_handler))
|
||||
.route_layer(from_fn_with_state(
|
||||
auth_state.clone(),
|
||||
@@ -204,8 +206,16 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
with_extras.with_state(state)
|
||||
}
|
||||
|
||||
async fn not_implemented() -> impl IntoResponse {
|
||||
(StatusCode::NOT_IMPLEMENTED, "501 Not Implemented")
|
||||
async fn openapi_json_handler(State(registry): State<Arc<OperationRegistry>>) -> impl IntoResponse {
|
||||
let spec = to_openapi(®istry);
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/json"),
|
||||
)],
|
||||
axum::Json(spec.raw),
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -684,4 +694,22 @@ mod tests {
|
||||
);
|
||||
assert!(response.contains("location: https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_json_route_serves_gateway_spec() {
|
||||
let adapter = HttpAdapter::new(provider(), empty_registry());
|
||||
let request = b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
let response = serve_and_read(adapter, request).await;
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 200"),
|
||||
"expected 200 for /openapi.json, got: {response}"
|
||||
);
|
||||
assert!(response.contains("\"openapi\""));
|
||||
assert!(response.contains("\"/search\""));
|
||||
assert!(response.contains("\"/schema\""));
|
||||
assert!(response.contains("\"/call\""));
|
||||
assert!(response.contains("\"/batch\""));
|
||||
assert!(response.contains("\"/subscribe\""));
|
||||
assert!(response.contains("\"1.0.0\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ mod tests {
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerRegistration, OperationProvenance,
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType};
|
||||
use alknet_core::auth::{AuthToken, Identity};
|
||||
@@ -376,46 +376,52 @@ mod tests {
|
||||
|
||||
fn registry_with_echo() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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 mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/run",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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 mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret/op"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_spec("secret/op"),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -424,37 +430,43 @@ mod tests {
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut inner = OperationRegistry::new();
|
||||
for op in inner_ops {
|
||||
inner.register(op);
|
||||
inner.register(op).unwrap();
|
||||
}
|
||||
let inner = Arc::new(inner);
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
services_list_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
services_schema_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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(),
|
||||
Arc::clone(®.handler),
|
||||
reg.provenance,
|
||||
reg.composition_authority.clone(),
|
||||
reg.scoped_env.clone(),
|
||||
reg.capabilities.clone(),
|
||||
));
|
||||
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)
|
||||
}
|
||||
@@ -572,7 +584,7 @@ mod tests {
|
||||
let ops = vec![
|
||||
HandlerRegistration::new(
|
||||
external_spec("public/echo", AccessControl::default()),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -586,7 +598,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -625,7 +637,7 @@ mod tests {
|
||||
async fn schema_returns_full_spec_for_authorized_op() {
|
||||
let ops = vec![HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -657,7 +669,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -709,22 +721,26 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn batch_internal_op_returns_not_found_in_array() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret/op"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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 registry = Arc::new(registry);
|
||||
let router = build_router(registry, unused_provider());
|
||||
let req = Request::builder()
|
||||
@@ -823,14 +839,16 @@ mod tests {
|
||||
#[test]
|
||||
fn is_internal_op_detects_registered_internal_op() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
internal_spec("secret/op"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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"));
|
||||
}
|
||||
@@ -838,14 +856,16 @@ mod tests {
|
||||
#[test]
|
||||
fn is_internal_op_false_for_external_op() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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"));
|
||||
}
|
||||
|
||||
@@ -906,7 +926,7 @@ mod tests {
|
||||
let ops = vec![
|
||||
HandlerRegistration::new(
|
||||
external_spec("public/echo", AccessControl::default()),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -920,7 +940,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
@@ -953,7 +973,7 @@ mod tests {
|
||||
async fn schema_unknown_op_returns_404() {
|
||||
let ops = vec![HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
echo_handler(),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -18,7 +18,7 @@ mod tests {
|
||||
use alknet_call::protocol::wire::{EventEnvelope, ResponseEnvelope, EVENT_RESPONDED};
|
||||
use alknet_call::registry::context::AbortPolicy;
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerRegistration, OperationProvenance,
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{Identity, IdentityProvider};
|
||||
@@ -77,14 +77,18 @@ mod tests {
|
||||
|
||||
fn echo_registry() -> Arc<alknet_call::registry::registration::OperationRegistry> {
|
||||
let mut registry = alknet_call::registry::registration::OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo/run"),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -174,7 +178,9 @@ mod tests {
|
||||
assert!(!env.contains("worker/exec"));
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
external_spec("worker/exec"),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -30,7 +30,7 @@ mod tests {
|
||||
};
|
||||
use alknet_call::registry::env::{OperationEnv, PeerRef};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{Identity, IdentityProvider};
|
||||
@@ -113,7 +113,9 @@ mod tests {
|
||||
) -> HandlerRegistration {
|
||||
HandlerRegistration::new(
|
||||
external_spec(name, acl),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::FromCall,
|
||||
composition_authority,
|
||||
None,
|
||||
@@ -123,14 +125,18 @@ mod tests {
|
||||
|
||||
fn echo_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -315,7 +321,10 @@ mod tests {
|
||||
let ctx = hub_root_context(
|
||||
"hub-acl-ok",
|
||||
&["ui/dragged"],
|
||||
Some(CompositionAuthority::new("hub", vec!["ui:write".to_string()])),
|
||||
Some(CompositionAuthority::new(
|
||||
"hub",
|
||||
vec!["ui:write".to_string()],
|
||||
)),
|
||||
env.clone(),
|
||||
);
|
||||
|
||||
@@ -342,7 +351,10 @@ mod tests {
|
||||
let ctx = hub_root_context(
|
||||
"hub-acl-deny",
|
||||
&["ui/dragged"],
|
||||
Some(CompositionAuthority::new("hub", vec!["ui:read".to_string()])),
|
||||
Some(CompositionAuthority::new(
|
||||
"hub",
|
||||
vec!["ui:read".to_string()],
|
||||
)),
|
||||
env.clone(),
|
||||
);
|
||||
|
||||
@@ -448,9 +460,9 @@ mod tests {
|
||||
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
external_spec("ui/dragged", AccessControl::default()),
|
||||
make_handler(|input, ctx| async move {
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "echoed": input }))
|
||||
}),
|
||||
})),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -499,10 +511,12 @@ mod tests {
|
||||
assert!(conn.pending().lock().contains("ws-sub-root"));
|
||||
assert!(conn.pending().lock().contains("ws-sub-child"));
|
||||
|
||||
let failed = conn
|
||||
.pending()
|
||||
.lock()
|
||||
.fail_all(alknet_call::protocol::wire::CallError::internal("connection closed"));
|
||||
let failed =
|
||||
conn.pending()
|
||||
.lock()
|
||||
.fail_all(alknet_call::protocol::wire::CallError::internal(
|
||||
"connection closed",
|
||||
));
|
||||
assert!(failed.contains(&"ws-sub-root".to_string()));
|
||||
assert!(failed.contains(&"ws-sub-child".to_string()));
|
||||
assert!(conn.pending().lock().is_empty());
|
||||
@@ -526,10 +540,12 @@ mod tests {
|
||||
)
|
||||
};
|
||||
|
||||
let failed = conn
|
||||
.pending()
|
||||
.lock()
|
||||
.fail_all(alknet_call::protocol::wire::CallError::internal("connection closed"));
|
||||
let failed =
|
||||
conn.pending()
|
||||
.lock()
|
||||
.fail_all(alknet_call::protocol::wire::CallError::internal(
|
||||
"connection closed",
|
||||
));
|
||||
assert!(failed.contains(&"hub-call-inflight".to_string()));
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_millis(100), rx).await;
|
||||
@@ -566,7 +582,10 @@ mod tests {
|
||||
.await;
|
||||
let envelope: EventEnvelope = response.into();
|
||||
assert_eq!(envelope.r#type, EVENT_RESPONDED);
|
||||
assert_eq!(envelope.payload.get("output"), Some(&serde_json::json!({ "v": 9 })));
|
||||
assert_eq!(
|
||||
envelope.payload.get("output"),
|
||||
Some(&serde_json::json!({ "v": 9 }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -641,7 +660,7 @@ mod tests {
|
||||
};
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
handler,
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -667,10 +686,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_identity_resolved_at_upgrade_is_stored_on_connection() {
|
||||
let provider = Arc::new(
|
||||
StaticIdentityProvider::new()
|
||||
.with_token("browser-token", identity_with_scopes("browser-user", &["ui:read"])),
|
||||
);
|
||||
let provider = Arc::new(StaticIdentityProvider::new().with_token(
|
||||
"browser-token",
|
||||
identity_with_scopes("browser-user", &["ui:read"]),
|
||||
));
|
||||
let registry = echo_registry();
|
||||
let dp = dispatcher(registry, Arc::clone(&provider) as Arc<dyn IdentityProvider>);
|
||||
|
||||
@@ -693,4 +712,4 @@ mod tests {
|
||||
let peer_ids = composed_env.peer_ids();
|
||||
assert_eq!(peer_ids, vec!["browser-user".to_string()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ mod tests {
|
||||
};
|
||||
use alknet_call::registry::env::OperationEnv;
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, HandlerRegistration, OperationProvenance,
|
||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{AuthToken, Identity};
|
||||
@@ -330,77 +330,92 @@ mod tests {
|
||||
|
||||
fn echo_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/run",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(
|
||||
"admin/run",
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn registry_with_subscription() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let count = Arc::new(StdMutex::new(0u32));
|
||||
let handler = make_handler(move |_input, ctx| {
|
||||
let handler = make_streaming_handler(move |_input, ctx| {
|
||||
let counter = Arc::clone(&count);
|
||||
async move {
|
||||
let mut c = counter.lock().unwrap();
|
||||
*c += 1;
|
||||
let value = *c;
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "n": value }))
|
||||
}
|
||||
let mut c = counter.lock().unwrap();
|
||||
*c += 1;
|
||||
let value = *c;
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
serde_json::json!({ "n": value }),
|
||||
)])
|
||||
});
|
||||
registry.register(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
HandlerKind::Stream(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
services_list_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
services_schema_handler(Arc::clone(&inner)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::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();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -543,22 +558,26 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn handle_inbound_envelope_internal_op_yields_not_found() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"secret/op",
|
||||
OperationType::Query,
|
||||
Visibility::Internal,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"secret/op",
|
||||
OperationType::Query,
|
||||
Visibility::Internal,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = dispatcher(registry, provider);
|
||||
@@ -753,19 +772,18 @@ mod tests {
|
||||
let dp = dispatcher(registry, provider);
|
||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("ws-peer")));
|
||||
|
||||
let mut received = Vec::new();
|
||||
for i in 0..3 {
|
||||
let request = EventEnvelope::requested(
|
||||
format!("sub-{i}"),
|
||||
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
||||
);
|
||||
let out = handle_inbound_envelope(&dp, &conn, request)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(out.r#type, EVENT_RESPONDED);
|
||||
received.push(out.id);
|
||||
}
|
||||
assert_eq!(received.len(), 3);
|
||||
let request = EventEnvelope::requested(
|
||||
"sub-0",
|
||||
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
||||
);
|
||||
let out = handle_inbound_envelope(&dp, &conn, request)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(out.r#type, EVENT_ERROR);
|
||||
assert_eq!(
|
||||
out.payload.get("code"),
|
||||
Some(&serde_json::json!("INVALID_OPERATION_TYPE"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -868,7 +886,9 @@ mod tests {
|
||||
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
external_spec("ui/dragged", AccessControl::default()),
|
||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::FromCall,
|
||||
None,
|
||||
None,
|
||||
@@ -1044,28 +1064,27 @@ mod tests {
|
||||
drive_ws_session(socket, &dp, &conn).await;
|
||||
});
|
||||
|
||||
let mut got = Vec::new();
|
||||
for i in 0..3 {
|
||||
let request = EventEnvelope::requested(
|
||||
format!("sub-ws-{i}"),
|
||||
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
||||
);
|
||||
client
|
||||
.send_binary(serialize_envelope(&request).unwrap())
|
||||
.await;
|
||||
let request = EventEnvelope::requested(
|
||||
"sub-ws-0",
|
||||
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
||||
);
|
||||
client
|
||||
.send_binary(serialize_envelope(&request).unwrap())
|
||||
.await;
|
||||
|
||||
let msg = client.recv_timeout(Duration::from_secs(5)).await;
|
||||
match msg {
|
||||
MockMsg::Binary(bytes) => {
|
||||
let env: EventEnvelope = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(env.id, format!("sub-ws-{i}"));
|
||||
assert_eq!(env.r#type, EVENT_RESPONDED);
|
||||
got.push(env.id);
|
||||
}
|
||||
other => panic!("expected binary, got {other:?}"),
|
||||
let msg = client.recv_timeout(Duration::from_secs(5)).await;
|
||||
match msg {
|
||||
MockMsg::Binary(bytes) => {
|
||||
let env: EventEnvelope = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(env.id, "sub-ws-0");
|
||||
assert_eq!(env.r#type, EVENT_ERROR);
|
||||
assert_eq!(
|
||||
env.payload.get("code"),
|
||||
Some(&serde_json::json!("INVALID_OPERATION_TYPE"))
|
||||
);
|
||||
}
|
||||
other => panic!("expected binary, got {other:?}"),
|
||||
}
|
||||
assert_eq!(got.len(), 3);
|
||||
|
||||
client.close().await;
|
||||
server_handle.await.ok();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-30
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# Alknet Architecture
|
||||
@@ -102,6 +102,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
| [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed |
|
||||
| [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed |
|
||||
| [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted |
|
||||
| [049](decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -152,6 +153,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
||||
- **OQ-37**: ~~X.509 outgoing-only case~~ — **resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence)
|
||||
- **OQ-38**: WebTransport standalone relay service scope — the standalone relay (future `alknet-relay`, fork of iroh-relay with WebTransport proxy fallback) is distinct from the in-process ALPN-stream-proxy (ADR-040); scope question, not deferral
|
||||
- **OQ-39**: ~~`to_openapi` published-spec versioning~~ — **resolved by ADR-045** (`info.version` semver tracks the gateway endpoint contract, not the operation set; per-caller operations discovered via `/search`)
|
||||
- **OQ-41**: Stream operators library — a handler-level utility library (filter, map, batch, dedupe, window, etc. on `BoxStream<T>`), prior art in `@alkdev/pubsub/operators.ts`; feature extension, not an architectural decision (the architecture decision — stream composition is handler-level, not protocol-level — is made in ADR-049)
|
||||
|
||||
**Deferred (not active):**
|
||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-23
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# Call Protocol
|
||||
@@ -275,6 +275,7 @@ Error codes use an extensible string enum. The protocol defines the following **
|
||||
- `NOT_FOUND` — operation not in registry (or Internal op called from wire)
|
||||
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
|
||||
- `INVALID_INPUT` — input doesn't match the operation's JSON Schema
|
||||
- `INVALID_OPERATION_TYPE` — wrong dispatch path for the operation's type (`invoke()` called on a `Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-049)
|
||||
- `INTERNAL` — handler error, panic, connection failure
|
||||
- `TIMEOUT` — request timed out (retryable: true)
|
||||
|
||||
@@ -309,7 +310,7 @@ Local dispatch produces `ResponseEnvelope { request_id, result: Result<Value, Ca
|
||||
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
|
||||
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
|
||||
|
||||
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`.
|
||||
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`. The streaming dispatch path (`invoke_streaming()` → write each → write `call.completed`) produces these frames from a `StreamingHandler`'s stream; the single-response path (`invoke()` → write one) produces them from a `Handler`'s future. See ADR-049 and [operation-registry.md](operation-registry.md#handler).
|
||||
|
||||
### Protocol Operations
|
||||
|
||||
@@ -405,10 +406,14 @@ The `CallAdapter::handle()` method:
|
||||
|
||||
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
|
||||
2. For each accepted stream, reads `EventEnvelope` frames using `FrameFramedReader`
|
||||
3. Dispatches `call.requested` events to the operation registry
|
||||
3. Dispatches `call.requested` events to the operation registry, **branching on `op_type`** (ADR-049):
|
||||
- **`Query` / `Mutation`** → `OperationRegistry::invoke()` → write one `call.responded` (or `call.error`) `EventEnvelope` frame
|
||||
- **`Subscription`** → `OperationRegistry::invoke_streaming()` → write each `call.responded` `EventEnvelope` as the stream yields → write `call.completed` on natural stream end (or `call.error` if the stream yields an `Err`). `deadline: None` for subscriptions (unbounded — see Timeouts below). Abort (`call.aborted` arriving for the request ID, or the stream being dropped) cascades per ADR-016: the stream future is dropped, `Drop` guards release the handler's resources, and descendants are aborted.
|
||||
4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
|
||||
5. Manages `PendingRequestMap` for outgoing calls initiated by the server
|
||||
|
||||
The streaming branch is the server-side path that makes `Subscription` operations work end-to-end. Without it, a `Subscription` op registered with a `StreamingHandler` had no server-side dispatch path — the handler produced a stream but the dispatcher only read one `ResponseEnvelope` and closed. ADR-049 adds the `StreamingHandler` type and the `invoke_streaming()` dispatch path; this section wires them into the accept loop. See [operation-registry.md](operation-registry.md#handler) for the `Handler` / `StreamingHandler` / `HandlerKind` types.
|
||||
|
||||
For outgoing calls (server → client), the adapter:
|
||||
1. Opens a bidirectional stream with `connection.open_bi()`
|
||||
2. Sends `call.requested` on that stream
|
||||
@@ -562,6 +567,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
||||
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -615,4 +621,5 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source)
|
||||
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
||||
- ADR-034: Outgoing-only X.509 and the three peer roles
|
||||
- ADR-049: Streaming handler for subscriptions (server-side streaming dispatch path)
|
||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-28
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# alknet-call — Client and Adapters
|
||||
@@ -323,8 +323,21 @@ The flow (ADR-017 §3):
|
||||
3. For each discovered op, construct a `HandlerRegistration`:
|
||||
- `spec` mirrors the remote op's name (with optional prefix), namespace,
|
||||
type, schemas, access control.
|
||||
- `handler` is a forwarding handler: sends `call.requested` through the
|
||||
`CallConnection`, awaits `call.responded` (or streams for subscriptions).
|
||||
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-049):
|
||||
- `Query` / `Mutation` → a `Handler` (registered as `HandlerKind::Once`):
|
||||
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
||||
the single `call.responded` (or `call.error`), returns the
|
||||
`ResponseEnvelope`.
|
||||
- `Subscription` → a `StreamingHandler` (registered as
|
||||
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
||||
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
|
||||
streaming path, already implemented), maps it to a
|
||||
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
|
||||
each `call.responded` the remote sends becomes a stream item; the
|
||||
remote's `call.completed` ends the stream (→ wire `call.completed`);
|
||||
`call.aborted` drops the stream (cascade per ADR-016). No truncation,
|
||||
no first-value fallback — a `from_call`-imported subscription forwards
|
||||
the full remote stream.
|
||||
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
||||
(leaf — ADR-022).
|
||||
4. The caller registers the bundles via
|
||||
@@ -668,6 +681,7 @@ Based on the gap analysis and the downstream unblock chain:
|
||||
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
||||
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
||||
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
||||
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of `CallCredentials` |
|
||||
| Outgoing-only X.509 and three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
||||
| HD derivation for encryption keys | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-27
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# Operation Registry
|
||||
@@ -91,19 +91,75 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
|
||||
|
||||
### Handler
|
||||
|
||||
There are two handler types, one per dispatch shape — mirroring the
|
||||
TypeScript prior art (`@alkdev/operations/src/types.ts:62-78`:
|
||||
`OperationHandler` returns a single value; `SubscriptionHandler` returns an
|
||||
`AsyncGenerator`). The split is locked by ADR-049.
|
||||
|
||||
```rust
|
||||
pub type Handler = Arc<dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>> + Send + Sync>;
|
||||
/// Request/response handler — Query and Mutation operations.
|
||||
pub type Handler = Arc<
|
||||
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
/// Streaming handler — Subscription operations. Returns a stream of
|
||||
/// ResponseEnvelopes: each Ok(value) → call.responded, an Err → call.error
|
||||
/// (terminal — stream ends), natural stream end → call.completed.
|
||||
pub type StreamingHandler = Arc<
|
||||
dyn Fn(Value, OperationContext)
|
||||
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
/// Type alias for the boxed stream shape used by `invoke_streaming()` and
|
||||
/// `StreamingHandler` return values. The concrete library
|
||||
/// (`futures::stream::BoxStream<'static, T>` = `Pin<Box<dyn Stream<Item = T>
|
||||
/// + Send>>`) is a two-way-door implementation detail (ADR-049); the alias
|
||||
/// exists so the two spellings (the expanded form in `StreamingHandler` and
|
||||
/// the short form in `invoke_streaming()`) refer to the same type.
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
|
||||
```
|
||||
|
||||
Handlers are async — many operations (file I/O, HTTP service calls, irpc service calls) are inherently asynchronous. The handler receives an `async` runtime context and returns a `Future<Output = ResponseEnvelope>`.
|
||||
Both handlers are async — many operations (file I/O, HTTP service calls,
|
||||
irpc service calls, LLM streaming) are inherently asynchronous. A handler
|
||||
receives:
|
||||
|
||||
A handler receives:
|
||||
- `input: Value` — the deserialized `payload` from the `call.requested` event (always `serde_json::Value`)
|
||||
- `input: Value` — the deserialized `payload` from the `call.requested` event
|
||||
(always `serde_json::Value`)
|
||||
- `context: OperationContext` — request ID, identity, metadata, env
|
||||
|
||||
And returns a `ResponseEnvelope` containing the result or an error. `ResponseEnvelope` is defined in [call-protocol.md](call-protocol.md#responseenvelope) — it carries the request ID and a `Result<Value, CallError>`. Local dispatch produces it with no serialization overhead; the `CallAdapter` converts it to `EventEnvelope` for the wire.
|
||||
The **`Handler`** (request/response) returns a single `ResponseEnvelope`
|
||||
containing the result or an error. `ResponseEnvelope` is defined in
|
||||
[call-protocol.md](call-protocol.md#responseenvelope) — it carries the request
|
||||
ID and a `Result<Value, CallError>`. Local dispatch produces it with no
|
||||
serialization overhead; the `CallAdapter` converts it to `EventEnvelope` for
|
||||
the wire.
|
||||
|
||||
When a handler returns an error, the `CallError.code` is matched against the operation's declared `error_schemas` (ADR-023). If the code matches a declared `ErrorDefinition`, the `call.error` event carries that code and the error's detail payload. If it doesn't match, the `call.error` carries `INTERNAL`. This is how handler failures become typed errors on the wire instead of string-matched messages.
|
||||
The **`StreamingHandler`** (streaming) returns a `Pin<Box<dyn Stream<Item =
|
||||
ResponseEnvelope> + Send>>` — the stream analogue of `Handler`'s
|
||||
`Pin<Box<dyn Future<...>>>`. Each `Ok(value)` in the stream becomes a
|
||||
`call.responded` event; an `Err` becomes a `call.error` event (terminal — the
|
||||
stream ends after it); natural stream end becomes `call.completed`. The
|
||||
dispatch path converts each `ResponseEnvelope` to `EventEnvelope` exactly as
|
||||
it does for the single-response case — no new wire-format concept is
|
||||
introduced. See ADR-049 and [call-protocol.md](call-protocol.md) §"CallAdapter
|
||||
Stream Handling".
|
||||
|
||||
When a handler returns an error, the `CallError.code` is matched against the operation's declared `error_schemas` (ADR-023). If the code matches a declared `ErrorDefinition`, the `call.error` event carries that code and the error's detail payload. If it doesn't match, the `call.error` carries `INTERNAL`. This is how handler failures become typed errors on the wire instead of string-matched messages. The same matching applies to `Err` values yielded by a `StreamingHandler`.
|
||||
|
||||
A `make_streaming_handler()` helper (analogue of `make_handler()`) wraps a
|
||||
stream-producing closure into a `StreamingHandler`:
|
||||
|
||||
```rust
|
||||
pub fn make_streaming_handler<S, St>(f: S) -> StreamingHandler
|
||||
where
|
||||
S: Fn(Value, OperationContext) -> St + Send + Sync + 'static,
|
||||
St: Stream<Item = ResponseEnvelope> + Send + 'static,
|
||||
{
|
||||
Arc::new(move |input, context| Box::pin(f(input, context)))
|
||||
}
|
||||
```
|
||||
|
||||
### OperationContext
|
||||
|
||||
@@ -196,9 +252,10 @@ pub struct OperationRegistry {
|
||||
|
||||
The registry maps operation names to `HandlerRegistration` bundles. The curated layer (Layer 0) is a `HashMap<String, HandlerRegistration>`; session and connection overlays (Layers 1 and 2) are separate maps that the `CallAdapter` composes into the per-call `OperationContext.env` (ADR-024). See ADR-022 for the full registration model and ADR-024 for the layering model. Key methods:
|
||||
|
||||
- `register(registration)`: Add an operation to the curated layer at startup
|
||||
- `registration(name)`: Find a registration by operation name (checks active overlays first, then curated base — ADR-024). Returns spec, handler, provenance, composition authority, scoped env, capabilities.
|
||||
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
|
||||
- `register(registration)`: Add an operation to the curated layer at startup. Validates `handler` is the right `HandlerKind` for `spec.op_type` (Once for Query/Mutation, Stream for Subscription — ADR-049). Mismatch is a startup error.
|
||||
- `registration(name)`: Find a registration by operation name (checks active overlays first, then curated base — ADR-024). Returns spec, handler (`HandlerKind`), provenance, composition authority, scoped env, capabilities.
|
||||
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return a single `ResponseEnvelope` (request/response path — Query/Mutation). **Errors with `INVALID_OPERATION_TYPE` if the op is a `Subscription`** — `invoke()` is the wrong dispatch path for streaming ops; use `invoke_streaming()` (ADR-049).
|
||||
- `invoke_streaming(name, input, context)`: Look up, check ACL, invoke streaming handler, return a `ResponseStream` (the boxed stream alias — ADR-049) (streaming path — Subscription). Pre-handler errors (not-found, forbidden, `INVALID_OPERATION_TYPE` for a non-Subscription op) yield a single error `ResponseEnvelope` and end the stream. See ADR-049.
|
||||
- `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
|
||||
|
||||
### Request ID Generation
|
||||
@@ -229,15 +286,23 @@ The registration bundle carries everything the dispatch path needs to construct
|
||||
```rust
|
||||
pub struct HandlerRegistration {
|
||||
pub spec: OperationSpec,
|
||||
pub handler: Handler,
|
||||
pub handler: HandlerKind, // Once or Stream — validated against spec.op_type (ADR-049)
|
||||
pub provenance: OperationProvenance,
|
||||
pub composition_authority: Option<CompositionAuthority>, // None for leaves
|
||||
pub scoped_env: Option<ScopedOperationEnv>, // None for leaves
|
||||
pub scoped_env: Option<ScopedPeerEnv>, // None for leaves
|
||||
pub capabilities: Capabilities,
|
||||
// NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and
|
||||
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
|
||||
// not a per-op boolean. See ADR-029 §3.
|
||||
}
|
||||
|
||||
/// Which dispatch path a handler uses — locked by ADR-049.
|
||||
/// Validated against `spec.op_type` at registration:
|
||||
/// Query/Mutation → Once; Subscription → Stream. Mismatch is a startup error.
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
}
|
||||
```
|
||||
|
||||
#### OperationProvenance
|
||||
@@ -291,19 +356,22 @@ impl CompositionAuthority {
|
||||
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-015.
|
||||
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
|
||||
|
||||
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases:
|
||||
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases. The builder absorbs the `HandlerKind` wrapping internally — `.with_local()` and `.with_leaf()` take the raw `Handler` (or `StreamingHandler`) and wrap it in the right `HandlerKind` based on `spec.op_type` (ADR-049):
|
||||
|
||||
```rust
|
||||
// with_local: Local provenance, full bundle — all 5 args required.
|
||||
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
|
||||
// The builder inspects spec.op_type and wraps in HandlerKind::Once
|
||||
// (Query/Mutation) or HandlerKind::Stream (Subscription) automatically.
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
|
||||
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
// Agent handler (Local, composes — authority + scoped env + capabilities)
|
||||
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
|
||||
// Agent handler (Local, Subscription — streams call.responded as the
|
||||
// LLM generates tokens; builder wraps in HandlerKind::Stream)
|
||||
.with_local(agent_chat_spec(), Arc::new(agent_chat_streaming_handler),
|
||||
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
|
||||
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
|
||||
Capabilities::new().with_api_key("google", google_api_key))
|
||||
@@ -318,6 +386,8 @@ The CLI binary (or assembly layer) constructs the registry and passes it to the
|
||||
|
||||
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
|
||||
|
||||
**`OperationEnv` is request/response-only** (ADR-049). It returns a single `ResponseEnvelope` — no streaming variant exists. Calling `invoke()` on a `Subscription` op produces `CallError { code: "INVALID_OPERATION_TYPE", ... }` — composition cannot truncate a stream to its first value. Stream composition (filter, map, combine, window, dedupe) is a handler-level concern, not a protocol composition concern; see ADR-049 for the rationale and the `@alkdev/pubsub` `operators.ts` prior art.
|
||||
|
||||
```rust
|
||||
/// The composition dispatch trait. A handler composes child operations
|
||||
/// through its `OperationContext.env` (which implements this trait).
|
||||
@@ -673,10 +743,11 @@ let registry = OperationRegistryBuilder::new()
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||
// Agent handler (Local, composes — full bundle via .with())
|
||||
// Agent handler (Local, Subscription — composes; streaming handler
|
||||
// wrapped in HandlerKind::Stream by the builder per ADR-049)
|
||||
.with(HandlerRegistration {
|
||||
spec: agent_chat_spec(),
|
||||
handler: Arc::new(agent_chat_handler),
|
||||
handler: HandlerKind::Stream(Arc::new(agent_chat_streaming_handler)),
|
||||
provenance: OperationProvenance::Local,
|
||||
composition_authority: Some(CompositionAuthority::new(
|
||||
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
|
||||
@@ -750,6 +821,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-014.
|
||||
- **Metadata does not propagate through composition.** `OperationEnv::invoke()` constructs fresh metadata for nested calls (`HashMap::new()`), not the parent's metadata. This prevents a handler that accidentally places a secret in metadata from leaking it to child operations — and if a child is a `from_call` operation (ADR-017), across the wire to a remote node. The tracing link is `parent_request_id`, not metadata propagation. See ADR-014.
|
||||
- **Provenance determines composition capability.** Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `composition_authority: None` and `scoped_env: None` — they don't compose, so they don't need authority or reachability bounds. See ADR-022.
|
||||
- **`HandlerKind` matches `op_type`** (ADR-049). `Query`/`Mutation` ops register a `HandlerKind::Once(Handler)`; `Subscription` ops register a `HandlerKind::Stream(StreamingHandler)`. Mismatch is a startup error. `invoke()` on a `Subscription` and `invoke_streaming()` on a `Query`/`Mutation` both return `INVALID_OPERATION_TYPE`. `OperationEnv::invoke()` (composition) is request/response-only and errors with `INVALID_OPERATION_TYPE` on `Subscription` ops — stream composition is a handler-level concern, not a protocol composition concern.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -768,6 +840,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~`remote_safe` marking on `HandlerRegistration`~~ — superseded by ADR-029 |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -814,4 +887,5 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- ADR-029: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
|
||||
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source = `Identity.id` = `PeerEntry.peer_id`)
|
||||
- ADR-032: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
|
||||
- ADR-049: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
|
||||
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-01
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# HTTP Adapters — from_openapi and to_openapi
|
||||
@@ -123,8 +123,8 @@ The adapter:
|
||||
|
||||
### Forwarding handler
|
||||
|
||||
The forwarding handler is the `Arc<dyn Handler>` stored in the
|
||||
`HandlerRegistration`. At call time, it:
|
||||
The forwarding handler is stored in the `HandlerRegistration` as a
|
||||
`HandlerKind` (ADR-049). At call time, it:
|
||||
|
||||
1. Reads the call input (`serde_json::Value`).
|
||||
2. Builds the outbound HTTP request:
|
||||
@@ -138,15 +138,23 @@ The forwarding handler is the `Arc<dyn Handler>` stored in the
|
||||
below).
|
||||
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
|
||||
binary — same content-type branching as the TS `createHTTPOperation`),
|
||||
wraps it in a `ResponseEnvelope`, returns.
|
||||
wraps it in a `ResponseEnvelope`, returns. Registered as
|
||||
`HandlerKind::Once` — a `Handler` returning a single
|
||||
`ResponseEnvelope`.
|
||||
5. For a `Subscription` (`text/event-stream` response): streams
|
||||
`call.responded` events as the SSE chunks arrive (same SSE parsing as
|
||||
the TS `parseSSEFrames`), then `call.completed` on stream end.
|
||||
the TS `parseSSEFrames`), then the stream ends on SSE close (which
|
||||
becomes `call.completed` on the wire). Registered as
|
||||
`HandlerKind::Stream` — a `StreamingHandler` returning a
|
||||
`BoxStream<ResponseEnvelope>` (ADR-049). Each SSE `data:` frame becomes
|
||||
a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) becomes a single
|
||||
`ResponseEnvelope::error()` and ends the stream.
|
||||
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
|
||||
HTTP status code (see Error Fidelity below), returns a `CallError`.
|
||||
|
||||
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
|
||||
the registry dispatches. `alknet-call` never sees `reqwest`.
|
||||
The handler is opaque to the `CallAdapter` — it's a `HandlerKind` the
|
||||
registry dispatches (via `invoke()` for `Once`, `invoke_streaming()` for
|
||||
`Stream`). `alknet-call` never sees `reqwest`.
|
||||
|
||||
### HTTP client (reqwest)
|
||||
|
||||
@@ -319,9 +327,9 @@ factoring recommendation (thin shared struct, not a trait).
|
||||
|
||||
`from_openapi` maps OpenAPI non-2xx response status codes to
|
||||
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
|
||||
`from_openapi` must not produce error codes that collide with the five
|
||||
`from_openapi` must not produce error codes that collide with the six
|
||||
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
|
||||
`INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
|
||||
`INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
|
||||
`HTTP_` and the status number:
|
||||
|
||||
```rust
|
||||
@@ -423,6 +431,7 @@ once published, the 5-endpoint gateway shape is one-way.
|
||||
| HTTP path = operation path (~~direct-call surface~~) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) → superseded by [ADR-047](../../decisions/047-remove-direct-call-http-surface.md) | ~~`POST /{service}/{op}` → `call.requested`~~ — removed; the gateway `/call` with `{ operation, input }` is the sole invoke path; `to_openapi` describes the gateway, not a per-operation surface |
|
||||
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause |
|
||||
| `to_openapi` published-spec versioning | [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | `info.version` semver tracks the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_openapi` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE response → `BoxStream<ResponseEnvelope>`; `Query`/`Mutation` stay `HandlerKind::Once` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-01
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# HTTP MCP — from_mcp and to_mcp
|
||||
@@ -78,10 +78,12 @@ The adapter:
|
||||
namespace prefix is configured — same local-naming sugar as
|
||||
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
|
||||
- `spec.namespace` = the configured `namespace`.
|
||||
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
|
||||
spec doesn't have a native streaming/tool-subscription distinction
|
||||
— `tools/call` returns a result. If MCP adds a streaming-tool
|
||||
extension, a `Subscription` mapping would be added.)
|
||||
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
|
||||
spec doesn't have a native streaming/tool-subscription distinction
|
||||
— `tools/call` returns a result. If MCP adds a streaming-tool
|
||||
extension, a `Subscription` mapping would be added.) All `from_mcp`
|
||||
handlers are `HandlerKind::Once` (ADR-049); `from_mcp` never
|
||||
produces a `StreamingHandler`.
|
||||
- `spec.visibility` = `Internal` (adapter-registered, ADR-015).
|
||||
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
|
||||
- `spec.output_schema` = depends on whether the tool declares
|
||||
@@ -128,8 +130,9 @@ At call time, the `from_mcp` forwarding handler:
|
||||
registration (the MCP server is a persistent streamable HTTP
|
||||
endpoint, not a per-call connection).
|
||||
|
||||
The handler is opaque to the `CallAdapter` — `Arc<dyn Handler>` the
|
||||
registry dispatches. `alknet-call` never sees rmcp.
|
||||
The handler is opaque to the `CallAdapter` — a `HandlerKind::Once`
|
||||
wrapping an `Arc<dyn Handler>` that the registry dispatches. `alknet-call`
|
||||
never sees rmcp.
|
||||
|
||||
### Output handling (structuredContent vs content blocks)
|
||||
|
||||
@@ -222,7 +225,12 @@ The gateway exposes only `Query` and `Mutation` operations
|
||||
(request/response). `Subscription` operations (streaming) are filtered
|
||||
out of `search` results and cannot be invoked via `call` — MCP tool
|
||||
calls are request/response by protocol design; streaming subscriptions
|
||||
don't fit the LLM tool-call pattern. See ADR-041 §2.
|
||||
don't fit the LLM tool-call pattern. This is unaffected by ADR-049
|
||||
(streaming handlers): the `StreamingHandler` type and `invoke_streaming()`
|
||||
dispatch path exist in `alknet-call` and are used by `to_openapi`'s
|
||||
`/subscribe` endpoint, but `to_mcp` does not expose them — it filters by
|
||||
`op_type` and only dispatches `Query`/`Mutation` via `invoke()`. See
|
||||
ADR-041 §2.
|
||||
|
||||
#### `to_mcp` service behavior
|
||||
|
||||
@@ -263,19 +271,19 @@ axum route handlers) are genuinely per-gateway and are not shared.
|
||||
|
||||
Research findings
|
||||
(`docs/research/alknet-http-gateway-factoring/findings.md`) recommend
|
||||
extracting a **thin shared spine** (a concrete struct holding
|
||||
`Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
|
||||
`resolve + build_context + invoke` method returning a
|
||||
`ResponseEnvelope`), **not** a `GatewayDispatch` trait or gateway
|
||||
abstraction. The spine is small (~15–30 lines per endpoint), but it is
|
||||
the one place where a divergence bug (identity resolved differently,
|
||||
`OperationContext.internal` set inconsistently, `CallError` mapped
|
||||
asymmetrically) would be a security/correctness issue. The
|
||||
server-integration and wire-framing layers stay per-gateway; a third
|
||||
gateway (GraphQL, gRPC) is not on the horizon, and if one appears its
|
||||
server-integration layer needs its own shape anyway. This is an
|
||||
implementation factoring note, not an ADR — the decision is internal to
|
||||
`alknet-http` and does not cross crate boundaries.
|
||||
extracting a **thin shared spine** (the concrete `GatewayDispatch` struct
|
||||
holding `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
|
||||
`resolve + build_context + invoke` method returning a `ResponseEnvelope`,
|
||||
named in ADR-049 and extended with `invoke_streaming()` for the streaming
|
||||
path), **not** a trait or gateway abstraction. The spine is small (~15–30
|
||||
lines per endpoint), but it is the one place where a divergence bug
|
||||
(identity resolved differently, `OperationContext.internal` set
|
||||
inconsistently, `CallError` mapped asymmetrically) would be a
|
||||
security/correctness issue. The server-integration and wire-framing layers
|
||||
stay per-gateway; a third gateway (GraphQL, gRPC) is not on the horizon,
|
||||
and if one appears its server-integration layer needs its own shape anyway.
|
||||
This is an implementation factoring note, not an ADR — the decision is
|
||||
internal to `alknet-http` and does not cross crate boundaries.
|
||||
|
||||
### No-Env-Vars
|
||||
|
||||
@@ -340,6 +348,7 @@ every other HTTP request.
|
||||
| Error fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | MCP tool errors mapped to `ErrorDefinition`s |
|
||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||
| MCP clients are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
|
||||
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_mcp` handlers are always `HandlerKind::Once` (MCP tools are request/response); `to_mcp` excludes `Subscription` ops (unchanged by the streaming handler) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-01
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# HTTP Server
|
||||
@@ -194,13 +194,22 @@ The request body is `{ operation, input }` (the same flat JSON shape as
|
||||
`Accept: text/event-stream` on the `POST`). The axum route handler:
|
||||
|
||||
- Sets `Content-Type: text/event-stream`.
|
||||
- For each `call.responded` event, writes an SSE `data:` frame (the
|
||||
event's `output` serialized as JSON).
|
||||
- On `call.completed`, closes the SSE stream (normal end).
|
||||
- On `call.aborted`, closes the stream with an SSE error event.
|
||||
- On HTTP client disconnect (detected as the response writer closing),
|
||||
sends `call.aborted` for the in-flight subscription, which cascades
|
||||
to descendants per ADR-016.
|
||||
- Calls `GatewayDispatch::invoke_streaming()` (ADR-049) — the streaming
|
||||
analogue of `invoke()`, returning a `BoxStream<ResponseEnvelope>`. The
|
||||
security invariants are identical to `invoke()`: `internal: false`,
|
||||
`forwarded_for: None`, same capabilities, same `scoped_env`, same ACL
|
||||
check before dispatch. The two methods diverge only on the return shape
|
||||
(stream vs single envelope).
|
||||
- For each `ResponseEnvelope` the stream yields, writes an SSE `data:` frame:
|
||||
`Ok(value)` → `data:` frame with the output serialized as JSON; `Err` →
|
||||
SSE error event with the `CallError` serialized, then close (an `Err` is
|
||||
terminal — the stream ends after it, matching the wire protocol's
|
||||
`call.error` semantics).
|
||||
- On natural stream end (the `StreamingHandler`'s stream completes), closes
|
||||
the SSE stream (normal end — corresponds to `call.completed` on the wire).
|
||||
- On `call.aborted` or HTTP client disconnect (detected as the response
|
||||
writer closing), drops the stream future — `Drop` guards release the
|
||||
handler's resources, and the abort cascade runs per ADR-016.
|
||||
|
||||
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
|
||||
([websocket.md](websocket.md)), the subscription projects directly
|
||||
@@ -209,6 +218,16 @@ no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project
|
||||
onto WebTransport bidirectional streams; see
|
||||
[webtransport.md](webtransport.md).
|
||||
|
||||
**The streaming dispatch path.** Pre-ADR-049, `subscribe_handler` called
|
||||
`GatewayDispatch::invoke()` (single response) and wrapped the one
|
||||
`ResponseEnvelope` in a one-event SSE stream — a placeholder that couldn't
|
||||
stream a real `Subscription` op. ADR-049 adds `GatewayDispatch::
|
||||
invoke_streaming()` and the underlying `OperationRegistry::
|
||||
invoke_streaming()`, giving `/subscribe` a real streaming dispatch path
|
||||
to call. See ADR-049 and [http-adapters.md](http-adapters.md) for the
|
||||
`from_openapi` SSE forwarding handler that feeds `StreamingHandler`s from
|
||||
external `text/event-stream` responses.
|
||||
|
||||
### One-directional projection (HTTP request/response)
|
||||
|
||||
The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection**
|
||||
@@ -446,6 +465,7 @@ two-way door (add/remove freely). See
|
||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) — full rationale in [websocket.md](websocket.md) |
|
||||
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
|
||||
| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision |
|
||||
| Streaming handler for subscriptions (`invoke_streaming()`) | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `GatewayDispatch::invoke_streaming()` returns `BoxStream<ResponseEnvelope>`; `/subscribe` pipes it to SSE; replaces the one-event placeholder with the real streaming dispatch path |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended by ADR-049 — protocol-level code list extended to six)
|
||||
|
||||
## Context
|
||||
|
||||
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
|
||||
no `error_schemas`. The `call.error` payload (call-protocol.md L128–134)
|
||||
carries a `code` and `message`, where `code` is one of five infrastructure
|
||||
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`.
|
||||
carries a `code` and `message`, where `code` is one of six infrastructure
|
||||
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`,
|
||||
`INTERNAL`, `TIMEOUT`.
|
||||
|
||||
These five codes cover **protocol-level failures** — the call protocol
|
||||
These six codes cover **protocol-level failures** — the call protocol
|
||||
itself can always fail to find an operation, deny access, reject bad input,
|
||||
time out, or hit an internal error. They are emitted by the dispatch
|
||||
machinery (the registry, the adapter), not by operation handlers.
|
||||
reject the wrong dispatch method for the operation type, time out, or hit
|
||||
an internal error. They are emitted by the dispatch machinery (the registry,
|
||||
the adapter), not by operation handlers. `INVALID_OPERATION_TYPE` was added
|
||||
by ADR-049 (streaming handler for subscriptions — `invoke()` called on a
|
||||
`Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`).
|
||||
|
||||
But operations also have **domain-level failures** that are not covered:
|
||||
|
||||
@@ -164,8 +168,8 @@ optional-array convention.
|
||||
```
|
||||
|
||||
- `code` — the error code. Either a protocol-level code (`NOT_FOUND`,
|
||||
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`) or an
|
||||
operation-level domain code from `error_schemas` (e.g.,
|
||||
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
|
||||
`TIMEOUT`) or an operation-level domain code from `error_schemas` (e.g.,
|
||||
`FILE_NOT_FOUND`, `RATE_LIMITED`).
|
||||
- `message` — human-readable error message. Unstructured — for logging and
|
||||
debugging, not for programmatic handling. Clients should switch on
|
||||
@@ -182,7 +186,7 @@ optional-array convention.
|
||||
|
||||
### 3. Protocol-level vs operation-level error codes
|
||||
|
||||
The five existing codes are **protocol-level** — emitted by the dispatch
|
||||
The six existing codes are **protocol-level** — emitted by the dispatch
|
||||
machinery, not by handlers:
|
||||
|
||||
| Code | Emitted by | Meaning |
|
||||
@@ -190,6 +194,7 @@ machinery, not by handlers:
|
||||
| `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) |
|
||||
| `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated |
|
||||
| `INVALID_INPUT` | Registry | Input doesn't match `input_schema` |
|
||||
| `INVALID_OPERATION_TYPE` | Registry / `OperationEnv` | Wrong dispatch path for the operation's type (`invoke()` on a `Subscription`, `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-049) |
|
||||
| `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
|
||||
| `TIMEOUT` | Adapter | Request timed out |
|
||||
|
||||
@@ -242,8 +247,9 @@ accordingly.
|
||||
```
|
||||
|
||||
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
|
||||
codes that collide with the five protocol-level codes (`NOT_FOUND`,
|
||||
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The adapter prefixes
|
||||
codes that collide with the six protocol-level codes (`NOT_FOUND`,
|
||||
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
|
||||
`TIMEOUT`). The adapter prefixes
|
||||
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`,
|
||||
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
|
||||
a naming convention — the `from_openapi` example above was previously shown
|
||||
@@ -401,6 +407,9 @@ enum instead of a generic `Result<Output, string>`.
|
||||
for OS-level permission issues)
|
||||
- docs/reviews/001-pre-implementation-architecture-sanity-check.md
|
||||
(finding C5, which this ADR resolves)
|
||||
- ADR-049: Streaming handler for subscriptions (amends this ADR's
|
||||
protocol-level code list — `INVALID_OPERATION_TYPE` added as the sixth
|
||||
protocol-level code)
|
||||
- docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
|
||||
of making failure typed and declared)
|
||||
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# ADR-049: Streaming Handler for Subscription Operations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The call protocol defines `Subscription` as a first-class operation type
|
||||
(ADR-012 lists `subscribe` as one of four top-level protocol operations;
|
||||
`OperationSpec.op_type` includes `Subscription`). The wire protocol supports
|
||||
streaming: five event types (`call.requested`, `call.responded`,
|
||||
`call.completed`, `call.aborted`, `call.error`), `PendingRequestMap::Subscribe`
|
||||
with an mpsc channel, `CallConnection::subscribe()` returning
|
||||
`impl Stream<Item = ResponseEnvelope>`, and a full streaming-subscribe example
|
||||
in `call-protocol.md`. The **client side** works — a client can subscribe to a
|
||||
remote stream and consume `call.responded` events until `call.completed`.
|
||||
|
||||
The **server side does not.** The `Handler` type in `alknet-call` is:
|
||||
|
||||
```rust
|
||||
pub type Handler = Arc<
|
||||
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
```
|
||||
|
||||
It returns a single `ResponseEnvelope`. `OperationRegistry::invoke()` returns
|
||||
one `ResponseEnvelope` and closes. `Dispatcher::handle_stream` calls
|
||||
`dispatch_requested` → `registry.invoke()` → writes one `EventEnvelope` frame →
|
||||
loops. A `Subscription` operation that should produce a *stream* of
|
||||
`call.responded` events followed by `call.completed` has no way to express that
|
||||
through this handler signature.
|
||||
|
||||
This is a **spec gap that should not have shipped.** The TypeScript predecessor
|
||||
(`@alkdev/operations`, from which the Rust port was derived) had two distinct
|
||||
handler types:
|
||||
|
||||
```typescript
|
||||
type OperationHandler<I, O, C> = (input: I, context: C) => Promise<O> | O;
|
||||
type SubscriptionHandler<I, O, C> = (input: I, context: C) => AsyncGenerator<O, void, unknown>;
|
||||
```
|
||||
|
||||
The TS registry (`registry.ts:21`) stored them as a union, validated at
|
||||
registration that `SUBSCRIPTION` ops get an `AsyncGeneratorFunction`, and the
|
||||
server-side dispatch (`call.ts:341-349`, `buildCallHandler`) branched on
|
||||
`op_type`: `SUBSCRIPTION` → iterate the async generator, `respond()` for each,
|
||||
then `complete()`; else → `execute()`, single `respond()`. The Rust port
|
||||
collapsed the union into a single `Handler` returning one `ResponseEnvelope`,
|
||||
losing the streaming path. The fix is to restore it.
|
||||
|
||||
The downstream consequences of the gap:
|
||||
|
||||
- **`/subscribe` HTTP endpoint** (`GatewayDispatch::invoke()` →
|
||||
`subscribe_handler`) wraps a single `ResponseEnvelope` in a one-event SSE
|
||||
stream. A real `Subscription` operation (e.g., `agent/chat` streaming LLM
|
||||
tokens) cannot stream through it.
|
||||
- **`from_call` forwarding** for a `Subscription` op calls
|
||||
`CallConnection::call_with_payload()` (single response), not
|
||||
`CallConnection::subscribe()` (stream). A `from_call`-imported subscription
|
||||
truncates to the first value.
|
||||
- **`from_openapi` forwarding** for a `text/event-stream` response returns one
|
||||
`ResponseEnvelope` instead of streaming the SSE chunks.
|
||||
|
||||
All three are symptoms of the same root cause: the `Handler` type cannot
|
||||
produce a stream.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `StreamingHandler` type (alongside `Handler`)
|
||||
|
||||
Add a streaming handler type that returns a stream of `ResponseEnvelope`s,
|
||||
mirroring the TS `SubscriptionHandler` / `OperationHandler` split:
|
||||
|
||||
```rust
|
||||
pub type StreamingHandler = Arc<
|
||||
dyn Fn(Value, OperationContext)
|
||||
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
```
|
||||
|
||||
Each `Ok(value)` in the stream becomes a `call.responded` event. An `Err`
|
||||
becomes a `call.error` event (terminal — the stream ends). Natural stream end
|
||||
becomes `call.completed`. The dispatch path converts each `ResponseEnvelope` to
|
||||
`EventEnvelope` exactly as it does today for the single-response case — no new
|
||||
wire-format concept is introduced.
|
||||
|
||||
A `make_streaming_handler()` helper (analogue of `make_handler()`) wraps an
|
||||
async generator / stream-producing closure into a `StreamingHandler`.
|
||||
|
||||
### 2. `HandlerKind` enum on `HandlerRegistration`
|
||||
|
||||
```rust
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
}
|
||||
|
||||
pub struct HandlerRegistration {
|
||||
pub spec: OperationSpec,
|
||||
pub handler: HandlerKind, // validated against spec.op_type at registration
|
||||
pub provenance: OperationProvenance,
|
||||
pub composition_authority: Option<CompositionAuthority>,
|
||||
pub scoped_env: Option<ScopedPeerEnv>,
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
```
|
||||
|
||||
Registration validates: `Query` / `Mutation` → `HandlerKind::Once`;
|
||||
`Subscription` → `HandlerKind::Stream`. Mismatch is a startup error (same as
|
||||
the TS `validateSubscriptionHandler`). The enum makes the "one or the other,
|
||||
matching `op_type`" invariant type-level rather than two `Option`s validated at
|
||||
runtime.
|
||||
|
||||
### 3. `OperationRegistry::invoke_streaming()`
|
||||
|
||||
```rust
|
||||
impl OperationRegistry {
|
||||
/// Dispatch a Subscription operation. Returns a stream of
|
||||
/// ResponseEnvelopes. Errors (not-found, forbidden, invalid operation
|
||||
/// type) yield a single ResponseEnvelope::error and end the stream.
|
||||
pub fn invoke_streaming(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
context: OperationContext,
|
||||
) -> BoxStream<ResponseEnvelope>;
|
||||
}
|
||||
```
|
||||
|
||||
`invoke_streaming()` performs the same visibility + ACL checks as `invoke()`,
|
||||
then dispatches to the `StreamingHandler`. Pre-handler errors (not-found,
|
||||
forbidden) produce a single error `ResponseEnvelope` and end the stream
|
||||
(matching the single-response path's behavior, just on a stream).
|
||||
|
||||
### 4. `OperationRegistry::invoke()` errors on `Subscription`
|
||||
|
||||
`invoke()` is the request/response dispatch path. Calling it on a
|
||||
`Subscription` op is a type mismatch — a streaming operation dispatched through
|
||||
the request/response path. It returns a `ResponseEnvelope` carrying
|
||||
`CallError { code: "INVALID_OPERATION_TYPE", ... }` (a new protocol-level
|
||||
code):
|
||||
|
||||
```
|
||||
INVALID_OPERATION_TYPE
|
||||
```
|
||||
|
||||
(`retryable: false`, `details: None`). This is the wire-format addition: a
|
||||
sixth protocol-level error code. It signals "you called the wrong dispatch
|
||||
method for this operation's type" — distinct from `INVALID_INPUT` (schema
|
||||
mismatch) and `INTERNAL` (handler failure). Clients should treat unknown codes
|
||||
as `INTERNAL` with `retryable: false` (the existing rule); `INVALID_OPERATION_
|
||||
TYPE` is a permanent client-side programming error, not a transient failure.
|
||||
|
||||
### 5. `OperationEnv::invoke()` errors on `Subscription`
|
||||
|
||||
`OperationEnv::invoke()` (composition) stays request/response-only. It returns
|
||||
a single `ResponseEnvelope`. Calling it on a `Subscription` op produces the
|
||||
same `INVALID_OPERATION_TYPE` error — composition cannot truncate a stream to
|
||||
its first value. This is a clean architectural boundary, not a deferral:
|
||||
|
||||
- **`OperationEnv` composition** is "call a child operation, get a result"
|
||||
(the `OperationHandler` model). It is request/response by construction.
|
||||
- **Stream composition** (filter, map, combine, window, dedupe) is a
|
||||
handler-level concern. A handler that produces a stream transforms it with
|
||||
stream operators at the handler level, not through `OperationEnv`. The
|
||||
`@alkdev/pubsub` `operators.ts` is the prior art for this model: 13 operators
|
||||
(`filter`, `map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, etc.)
|
||||
that operate on `AsyncIterable<T>`, distinct from the request/response
|
||||
composition. In Rust, the analogues operate on `BoxStream<T>`.
|
||||
- No `invoke_streaming()` is added to `OperationEnv`. The protocol composition
|
||||
surface is request/response; stream manipulation is handler-internal.
|
||||
|
||||
### 6. Server-side dispatch branches on `op_type`
|
||||
|
||||
`Dispatcher::handle_stream` / `dispatch_requested` gains a branch on
|
||||
`op_type`:
|
||||
|
||||
- `Subscription` → `registry.invoke_streaming()` → for each `ResponseEnvelope`
|
||||
in the stream, write `EventEnvelope` to the wire → write `call.completed` on
|
||||
stream end.
|
||||
- `Query` / `Mutation` → `registry.invoke()` → write one `EventEnvelope`
|
||||
(existing path, unchanged).
|
||||
|
||||
The streaming branch sets `deadline: None` for subscriptions (unbounded —
|
||||
already specced in `call-protocol.md` Timeouts) and wires abort cascade
|
||||
(ADR-016): if `call.aborted` arrives for a streaming request, the stream is
|
||||
dropped (Rust `Drop` releases the handler's resources).
|
||||
|
||||
### 7. `GatewayDispatch::invoke_streaming()` (alknet-http)
|
||||
|
||||
The shared dispatch spine gains a streaming variant:
|
||||
|
||||
```rust
|
||||
impl GatewayDispatch {
|
||||
pub async fn invoke_streaming(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> BoxStream<ResponseEnvelope>;
|
||||
}
|
||||
```
|
||||
|
||||
`invoke_streaming()` builds the root `OperationContext` identically to
|
||||
`invoke()` (same security invariants: `internal: false`, `forwarded_for:
|
||||
None`, same capabilities, same `scoped_env`), then calls
|
||||
`registry.invoke_streaming()`. The two gateways (`to_openapi`, `to_mcp`)
|
||||
diverge only on wire-framing; the security axis is provably identical between
|
||||
`invoke()` and `invoke_streaming()`.
|
||||
|
||||
The HTTP `/subscribe` handler calls `invoke_streaming()` and pipes the
|
||||
`BoxStream<ResponseEnvelope>` to SSE: each `Ok(value)` → SSE `data:` frame,
|
||||
`Err` → SSE error event + close, stream end → close. This replaces the current
|
||||
one-event `subscribe_stream_from_envelope` with the real streaming path.
|
||||
|
||||
### 8. `from_call` stream forwarding
|
||||
|
||||
The `from_call` forwarding handler construction branches on `op_type` during
|
||||
discovery:
|
||||
|
||||
- `Query` / `Mutation` → existing `make_forwarding_handler()` (calls
|
||||
`CallConnection::call_with_payload()`, returns single `ResponseEnvelope`),
|
||||
registered as `HandlerKind::Once`.
|
||||
- `Subscription` → new `make_streaming_forwarding_handler()` (calls
|
||||
`CallConnection::subscribe()`, returns `impl Stream<Item =
|
||||
ResponseEnvelope>`, maps to `BoxStream<ResponseEnvelope>`), registered as
|
||||
`HandlerKind::Stream`.
|
||||
|
||||
A `from_call`-imported `Subscription` op forwards the remote stream end-to-end:
|
||||
the client-side `CallConnection::subscribe()` (already working) feeds a
|
||||
`StreamingHandler` that produces the stream. No truncation, no first-value
|
||||
fallback.
|
||||
|
||||
### 9. `from_openapi` SSE forwarding
|
||||
|
||||
The `from_openapi` forwarding handler construction branches on `op_type`
|
||||
(determined by `detectOperationType` — `text/event-stream` response →
|
||||
`Subscription`):
|
||||
|
||||
- `Query` / `Mutation` → existing forwarding handler (single HTTP request →
|
||||
single `ResponseEnvelope`), `HandlerKind::Once`.
|
||||
- `Subscription` → streaming forwarding handler (HTTP request → SSE response
|
||||
stream → parse SSE chunks → `BoxStream<ResponseEnvelope>`), `HandlerKind::
|
||||
Stream`.
|
||||
|
||||
The SSE parsing reuses the TS `parseSSEFrames` pattern: each SSE `data:` frame
|
||||
becomes a `ResponseEnvelope::ok()`, SSE stream end becomes stream end (→
|
||||
`call.completed`).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- `Subscription` operations work end-to-end: server-side handler →
|
||||
server-side dispatch → wire → HTTP `/subscribe` SSE → `from_call`
|
||||
forwarding → `from_openapi` SSE forwarding. No truncation, no broken paths.
|
||||
- The `Handler` / `StreamingHandler` split mirrors the TS prior art exactly,
|
||||
making the Rust port faithful to its source.
|
||||
- `HandlerKind` makes the "one or the other, matching `op_type`" invariant
|
||||
type-level (a `Once` variant for `Query`/`Mutation`, a `Stream` variant for
|
||||
`Subscription`) rather than a runtime check on two `Option`s.
|
||||
- Existing handlers (echo, discovery, from_openapi Query/Mutation, from_mcp,
|
||||
from_call Query/Mutation) are unchanged — they return a single
|
||||
`ResponseEnvelope` and register as `HandlerKind::Once`. The streaming path
|
||||
is additive to the existing handler surface.
|
||||
- `OperationEnv` composition stays request/response, preserving the
|
||||
composition model's simplicity. Stream composition is a handler-level
|
||||
concern, cleanly separated.
|
||||
- The new `INVALID_OPERATION_TYPE` protocol code catches dispatch-path misuse
|
||||
(calling `invoke()` on a `Subscription`) at the protocol level instead of
|
||||
silently producing wrong behavior.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- `HandlerRegistration.handler` changes type from `Handler` to `HandlerKind`.
|
||||
Existing code constructing `HandlerRegistration` bundles must wrap in
|
||||
`HandlerKind::Once(...)`. This is a mechanical change across handler
|
||||
construction sites (the builder's `.with_local()` / `.with_leaf()` /
|
||||
`.with()` methods absorb the wrapping internally, so most assembly-layer
|
||||
code is unaffected; direct `HandlerRegistration::new()` calls need the
|
||||
wrap).
|
||||
- A new protocol-level error code (`INVALID_OPERATION_TYPE`) is a wire-format
|
||||
addition. Existing clients that treat unknown codes as `INTERNAL` with
|
||||
`retryable: false` (the existing rule) handle it correctly — they just
|
||||
don't distinguish it from `INTERNAL` until updated. The code is distinct
|
||||
from all existing codes and from operation-level domain codes (no
|
||||
`HTTP_` prefix, no collision with the five existing protocol codes).
|
||||
- The `Dispatcher::handle_stream` streaming branch adds a stream-to-wire
|
||||
pump (read stream → write frames → write `call.completed`). This is new
|
||||
code in the hot dispatch path, but it is a straightforward `while let
|
||||
Some(envelope) = stream.next().await` loop, not a complex abstraction.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The `Handler` / `StreamingHandler` / `HandlerKind` API surface
|
||||
is what handlers are written against across crates (`alknet-call`,
|
||||
`alknet-http`, downstream consumers). Changing it after handlers exist is a
|
||||
rewrite. The `INVALID_OPERATION_TYPE` wire code is also one-way — once
|
||||
emitted, clients may handle it, and removing it would break those handlers.
|
||||
|
||||
The `HandlerKind` enum shape (`Once(Handler) | Stream(StreamingHandler)`) is
|
||||
the one-way commitment: two handler variants, validated against `op_type`.
|
||||
The concrete `BoxStream` library choice (`futures::stream::BoxStream` vs a
|
||||
custom type) is a two-way-door implementation detail within the one-way
|
||||
decision.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-012: Call Protocol Stream Model (defines `subscribe` as a top-level
|
||||
protocol operation; the streaming path this ADR implements)
|
||||
- ADR-017: Call Protocol Client and Adapter Contract (the adapter contract
|
||||
this ADR extends with the `StreamingHandler` variant)
|
||||
- ADR-022: Handler Registration, Provenance, and Composition Authority
|
||||
(`HandlerRegistration` gains `HandlerKind`)
|
||||
- ADR-015: Privilege Model and Authority Context (visibility/ACL checks run
|
||||
identically in `invoke_streaming()` as in `invoke()`)
|
||||
- ADR-016: Abort Cascade for Nested Calls (stream drop on abort; cascade
|
||||
through streaming handlers)
|
||||
- ADR-023: Operation Error Schemas (`INVALID_OPERATION_TYPE` is a
|
||||
protocol-level code, distinct from operation-level domain codes)
|
||||
- ADR-029: Peer-Graph Routing Model (`from_call` forwarding handlers gain the
|
||||
streaming variant)
|
||||
- ADR-009: One-Way Door Decision Framework (the `Handler` / `StreamingHandler`
|
||||
split is a one-way door — handler API surface)
|
||||
- `@alkdev/operations/src/types.ts:62-78` — TS prior art
|
||||
(`OperationHandler` / `SubscriptionHandler` split)
|
||||
- `@alkdev/operations/src/registry.ts:65-75` — TS prior art
|
||||
(`validateSubscriptionHandler` — runtime validation against `op_type`)
|
||||
- `@alkdev/operations/src/call.ts:341-349` — TS prior art (`buildCallHandler`
|
||||
branches on `op_type`: `SUBSCRIPTION` → iterate + complete; else → execute)
|
||||
- `@alkdev/pubsub/src/operators.ts` — stream operators prior art (filter,
|
||||
map, batch, dedupe, window, chain, join — handler-level stream
|
||||
composition, distinct from `OperationEnv` request/response composition)
|
||||
- Spec documents amended: `operation-registry.md`, `call-protocol.md`,
|
||||
`http-server.md`, `http-adapters.md`, `http-mcp.md`, `client-and-adapters.md`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-30
|
||||
last_updated: 2026-07-02
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
@@ -316,7 +316,12 @@ These questions are acknowledged but not active. They will be promoted to open w
|
||||
- **Status**: resolved
|
||||
- **Door type**: One-way (wire format), two-way (mapping mechanism)
|
||||
- **Priority**: high
|
||||
- **Resolution**: `OperationSpec` gains `error_schemas: Vec<ErrorDefinition>` where each `ErrorDefinition` carries a `code`, `description`, `schema` (JSON Schema for the error detail payload), and optional `http_status` (for adapter projection). The `call.error` payload gains an optional `details` field carrying the typed error payload. Protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`) are distinct from operation-level domain codes (`FILE_NOT_FOUND`, `RATE_LIMITED`, etc.) — protocol codes are emitted by the dispatch machinery, operation codes by handlers. `from_openapi`/`to_openapi` map OpenAPI response status codes to/from `ErrorDefinition`s, making the adapter contract from ADR-017 faithful on the error axis. `services/schema` exposes `error_schemas` for client code generation. See ADR-023.
|
||||
- **Resolution**: `OperationSpec` gains `error_schemas: Vec<ErrorDefinition>` where each `ErrorDefinition` carries a `code`, `description`, `schema` (JSON Schema for the error detail payload), and optional `http_status` (for adapter projection). The `call.error` payload gains an optional `details` field carrying the typed error payload. Protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
|
||||
`INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`) are distinct from
|
||||
operation-level domain codes (`FILE_NOT_FOUND`, `RATE_LIMITED`, etc.) —
|
||||
protocol codes are emitted by the dispatch machinery, operation codes by
|
||||
handlers. The six-code protocol-level list was extended from five by
|
||||
ADR-049 (`INVALID_OPERATION_TYPE`). `from_openapi`/`to_openapi` map OpenAPI response status codes to/from `ErrorDefinition`s, making the adapter contract from ADR-017 faithful on the error axis. `services/schema` exposes `error_schemas` for client code generation. See ADR-023.
|
||||
- **Cross-references**: ADR-017, ADR-023, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C5), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md)
|
||||
|
||||
## Theme: Call Client and Adapters
|
||||
@@ -909,4 +914,44 @@ is a feature extension, not an unmade architecture decision.
|
||||
system's structure, constraints, or API surface across crates.
|
||||
- **Cross-references**: ADR-014, ADR-017, ADR-035,
|
||||
[http-adapters.md](crates/http/http-adapters.md),
|
||||
[http-mcp.md](crates/http/http-mcp.md)
|
||||
[http-mcp.md](crates/http/http-mcp.md)
|
||||
|
||||
### OQ-41: Stream Operators Library
|
||||
|
||||
- **Origin**: [ADR-049](decisions/049-streaming-handler-for-subscriptions.md),
|
||||
[operation-registry.md](crates/call/operation-registry.md) §"OperationEnv"
|
||||
- **Status**: open (feature extension — a library to build, not a decision
|
||||
to make before implementation)
|
||||
- **Door type**: Two-way (additive utility library; no protocol or API-surface
|
||||
change)
|
||||
- **Priority**: low
|
||||
- **Resolution**: ADR-049 establishes that stream composition (filter, map,
|
||||
combine, window, dedupe) is a **handler-level concern**, not a protocol
|
||||
composition concern. `OperationEnv::invoke()` is request/response-only;
|
||||
stream manipulation happens at the handler level with stream operators on
|
||||
the `BoxStream<ResponseEnvelope>` the handler itself produces. The
|
||||
`@alkdev/pubsub` `operators.ts` is the prior art: 13 operators (`filter`,
|
||||
`map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, `reduce`,
|
||||
`groupBy`, `flat`, `pipe`, `toArray`) that operate on `AsyncIterable<T>`,
|
||||
forked from graphql-yoga's subscription implementation.
|
||||
|
||||
The Rust analogue — a stream-operators utility crate or module providing
|
||||
the same set of operators on `BoxStream<T>` / `impl Stream<Item = T>` — is
|
||||
a **feature extension**, not an unmade architectural decision. Handlers can
|
||||
produce streams today without it (`Box::pin(stream::iter(...))`,
|
||||
`async_stream::stream!`, `futures::stream` combinators all work); the
|
||||
operators library is a convenience that reduces boilerplate for handlers
|
||||
that transform streams (filter, batch, dedupe, window). No ADR is needed
|
||||
for the library itself — it's internal utility code that doesn't cross
|
||||
crate boundaries as a contract. An ADR would be warranted only if the
|
||||
operators become part of a public API surface (e.g., a handler-registration
|
||||
DSL that references operator names).
|
||||
|
||||
This OQ exists so the operators library is tracked and findable, not left
|
||||
as inline hedging in the spec docs. It is not a deferral of a decision —
|
||||
the architectural decision (stream composition is handler-level, not
|
||||
protocol-level) is made in ADR-049. This tracks the *implementation* of
|
||||
the utility library, which is scheduling work, not architecture work.
|
||||
- **Cross-references**: ADR-049,
|
||||
[operation-registry.md](crates/call/operation-registry.md) §"OperationEnv",
|
||||
`/workspace/@alkdev/pubsub/src/operators.ts` (TS prior art)
|
||||
172
tasks/call/client/from-call-streaming-forwarding.md
Normal file
172
tasks/call/client/from-call-streaming-forwarding.md
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
id: call/client/from-call-streaming-forwarding
|
||||
name: Implement from_call streaming forwarding handler (Subscription → CallConnection::subscribe → StreamingHandler)
|
||||
status: pending
|
||||
depends_on: [call/registry/streaming-handler-handlerkind]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Branch `from_call`'s forwarding handler construction on `op_type` so that a
|
||||
`Subscription` op discovered via `services/list` + `services/schema` registers a
|
||||
`StreamingHandler` (`HandlerKind::Stream`) that calls
|
||||
`CallConnection::subscribe()` and forwards the remote stream end-to-end.
|
||||
`Query`/`Mutation` ops keep the existing `make_forwarding_handler()` (single
|
||||
`call_with_payload()`, `HandlerKind::Once`). This closes the gap where a
|
||||
`from_call`-imported `Subscription` truncated to the first value.
|
||||
|
||||
This task depends on `call/registry/streaming-handler-handlerkind` (which
|
||||
introduces `HandlerKind::Stream` and `make_streaming_handler`). The
|
||||
`CallConnection::subscribe()` client-side path already works (it returns
|
||||
`impl Stream<Item = ResponseEnvelope>`); this task wires it into the forwarding
|
||||
handler.
|
||||
|
||||
### The branch in build_bundles
|
||||
|
||||
`build_bundles` currently constructs one `make_forwarding_handler()` per
|
||||
discovered op and wraps in `HandlerKind::Once`. Branch on
|
||||
`op_summary.op_type` (parsed from `services/schema`):
|
||||
|
||||
- `Query` / `Mutation` → `make_forwarding_handler()` (existing), wrap in
|
||||
`HandlerKind::Once`
|
||||
- `Subscription` → `make_streaming_forwarding_handler()` (new), wrap in
|
||||
`HandlerKind::Stream`
|
||||
|
||||
The `op_type` is already parsed in `rebuild_spec_for` (it reads `schema.op_type`
|
||||
and produces `OperationType::Subscription`). The `OpSummary` needs to carry the
|
||||
`op_type` (or the spec's `op_type` is read from the constructed `spec`). Read
|
||||
`spec.op_type` after `rebuild_spec_for` to decide the handler kind.
|
||||
|
||||
### make_streaming_forwarding_handler
|
||||
|
||||
```rust
|
||||
fn make_streaming_forwarding_handler(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
credentials_auth_token: Option<String>,
|
||||
) -> StreamingHandler {
|
||||
use crate::registry::registration::make_streaming_handler;
|
||||
make_streaming_handler(move |input, context| {
|
||||
let connection = Arc::clone(&connection);
|
||||
let remote_name = remote_name.clone();
|
||||
let auth_token = credentials_auth_token.clone();
|
||||
// The streaming forwarding handler calls subscribe() and forwards the
|
||||
// remote stream. forwarded_for is populated from context.identity
|
||||
// (ADR-032), same as the request/response forwarding handler.
|
||||
async move {
|
||||
// Build the payload (same as build_forwarded_payload, but for subscribe)
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context, auth_token.as_deref());
|
||||
// CallConnection::subscribe takes (operation_id, input); for the
|
||||
// forwarded payload path, we need a subscribe_with_payload variant,
|
||||
// OR we call subscribe(remote_name, input) and let it build the
|
||||
// payload. The forwarded_for + auth_token need to be in the payload,
|
||||
// so a subscribe_with_payload variant is needed (mirrors
|
||||
// call_with_payload). Check if CallConnection::subscribe can accept
|
||||
// a full payload — if not, add subscribe_with_payload().
|
||||
let stream = connection.subscribe_with_payload(payload).await;
|
||||
// Map the impl Stream<Item=ResponseEnvelope> to BoxStream<ResponseEnvelope>
|
||||
Box::pin(stream) as ResponseStream
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Coordinate with `CallConnection::subscribe`**: the existing
|
||||
`subscribe(operation_id, input)` builds the payload internally and does NOT
|
||||
populate `forwarded_for` or `auth_token`. The forwarding handler needs those
|
||||
fields (ADR-032). Two options:
|
||||
|
||||
1. Add `CallConnection::subscribe_with_payload(payload: Value)` (mirrors
|
||||
`call_with_payload`) that takes a caller-constructed payload. The forwarding
|
||||
handler builds the payload with `build_forwarded_payload` and calls
|
||||
`subscribe_with_payload`.
|
||||
2. Extend `subscribe()` to accept optional `forwarded_for` / `auth_token`.
|
||||
|
||||
Option 1 mirrors the existing `call` / `call_with_payload` split and is cleaner.
|
||||
Add `subscribe_with_payload()` alongside `subscribe()`.
|
||||
|
||||
### forwarded_for on the streaming payload
|
||||
|
||||
The streaming forwarding handler populates `forwarded_for` from
|
||||
`context.identity` exactly as the request/response forwarding handler does
|
||||
(ADR-032 §3) — reuse `build_forwarded_payload()`. The `auth_token` (hub's own
|
||||
call-protocol token) is also populated identically. No new payload-construction
|
||||
code; reuse the existing `build_forwarded_payload`.
|
||||
|
||||
### Abort cascade (ADR-016 §6)
|
||||
|
||||
The streaming forwarding handler's `parent_request_id` participates in the
|
||||
abort cascade: if the parent is aborted, the cascade reaches this handler,
|
||||
which sends `call.aborted` to the remote node; the remote node cascades to its
|
||||
own descendants. Cross-node abort is transparent. The `subscribe_with_payload`
|
||||
path registers the request in `PendingRequestMap` (the existing `subscribe()`
|
||||
does this); abort handling is already wired. Verify the streaming forwarding
|
||||
handler's stream is dropped on parent abort (the `SubscriptionStream`'s `Drop`
|
||||
or the pending entry's removal handles it).
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `OperationEnv::invoke_streaming()`.** Composition is request/response-only.
|
||||
- **No server-side dispatch changes.** The server-side streaming branch is
|
||||
`call/protocol/dispatch-streaming-branch`.
|
||||
- **No gateway changes.** The gateway streaming path is `http/gateway/invoke-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `build_bundles` branches on `spec.op_type`: `Subscription` → streaming
|
||||
forwarding handler (`HandlerKind::Stream`), `Query`/`Mutation` → existing
|
||||
`HandlerKind::Once`
|
||||
- [ ] `make_streaming_forwarding_handler()` constructs a `StreamingHandler`
|
||||
- [ ] Streaming forwarding handler calls `CallConnection::subscribe_with_payload()`
|
||||
(or `subscribe()` with the forwarded payload) and forwards the remote stream
|
||||
- [ ] `CallConnection::subscribe_with_payload(payload)` exists (mirrors
|
||||
`call_with_payload`) OR `subscribe()` accepts the forwarded payload
|
||||
- [ ] `forwarded_for` populated from `context.identity` (ADR-032) on the
|
||||
streaming payload (reuse `build_forwarded_payload`)
|
||||
- [ ] `auth_token` populated when present (reuse `build_forwarded_payload`)
|
||||
- [ ] Remote stream forwarded end-to-end: each `call.responded` → stream item,
|
||||
`call.completed` → stream end, `call.aborted` → stream dropped
|
||||
- [ ] No truncation, no first-value fallback
|
||||
- [ ] `composition_authority: None`, `scoped_env: None` for FromCall streaming
|
||||
leaves (same as Query/Mutation FromCall leaves)
|
||||
- [ ] Unit test: `build_bundles` with a `Subscription` op produces a
|
||||
`HandlerKind::Stream` registration
|
||||
- [ ] Unit test: `build_bundles` with a `Query` op produces `HandlerKind::Once`
|
||||
(unchanged)
|
||||
- [ ] Unit test: `make_streaming_forwarding_handler` produces a `StreamingHandler`
|
||||
that calls `subscribe_with_payload` (verify via payload capture, mirroring
|
||||
the existing `forwarding_handler_populates_forwarded_for` test)
|
||||
- [ ] Integration test: subscription forwarding streams remote events (if
|
||||
feasible with mock connection; otherwise document the integration test as
|
||||
deferred to a connection-level integration test)
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-call` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §8 (from_call stream forwarding)
|
||||
- docs/architecture/crates/call/client-and-adapters.md — §from_call (handler branched on op_type; Subscription → StreamingHandler via subscribe())
|
||||
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §3 (from_call flow), §6 (cross-node abort)
|
||||
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 §3 (forwarded_for population)
|
||||
|
||||
## Notes
|
||||
|
||||
> The client-side `CallConnection::subscribe()` already works — this task wires
|
||||
> it into the forwarding handler. The main new piece is
|
||||
> `subscribe_with_payload()` (mirroring `call_with_payload`) so the forwarding
|
||||
> handler can populate `forwarded_for` + `auth_token`. Reuse
|
||||
> `build_forwarded_payload` — no new payload-construction code. The abort
|
||||
> cascade is already wired via `PendingRequestMap`; verify the stream drops on
|
||||
> parent abort. The `OpSummary` / `build_bundles` needs the `op_type` to
|
||||
> branch — read it from the constructed `spec.op_type` (already parsed by
|
||||
> `rebuild_spec_for`). Cross-node abort is transparent via
|
||||
> `parent_request_id` (ADR-016 §6).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
174
tasks/call/protocol/dispatch-streaming-branch.md
Normal file
174
tasks/call/protocol/dispatch-streaming-branch.md
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
id: call/protocol/dispatch-streaming-branch
|
||||
name: Wire Dispatcher::handle_stream streaming branch (Subscription → invoke_streaming → write each → call.completed)
|
||||
status: pending
|
||||
depends_on: [call/registry/invoke-streaming]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Wire the server-side streaming dispatch branch in
|
||||
`Dispatcher::handle_stream` / `dispatch_requested`. When a `call.requested`
|
||||
arrives for a `Subscription` op, the dispatcher must call
|
||||
`OperationRegistry::invoke_streaming()` and pump the resulting
|
||||
`ResponseStream` to the wire: each `Ok(value)` → `call.responded` frame,
|
||||
`Err` → `call.error` frame (terminal), natural stream end → `call.completed`
|
||||
frame. This is the server-side path that makes `Subscription` operations work
|
||||
end-to-end — without it, a `StreamingHandler`-registered op had no server-side
|
||||
dispatch path.
|
||||
|
||||
This task depends on `call/registry/invoke-streaming` (which provides
|
||||
`invoke_streaming()`). It adds the `op_type` branch to the dispatch path and
|
||||
the stream-to-wire pump.
|
||||
|
||||
### The branch
|
||||
|
||||
`dispatch_requested` currently unconditionally calls `registry.invoke()` and
|
||||
returns one `ResponseEnvelope`. It needs to know the `op_type` to branch. Two
|
||||
options:
|
||||
|
||||
1. **Look up the registration to get `op_type` before dispatching.** The
|
||||
`build_root_context` already looks up the registration; expose `op_type`
|
||||
from it. Then branch: `Subscription` → streaming path, `Query`/`Mutation` →
|
||||
existing `invoke()` path.
|
||||
2. **Return an enum from `dispatch_requested`** (`DispatchResult::Once(ResponseEnvelope)`
|
||||
| `DispatchResult::Stream(ResponseStream)`) and let `handle_stream` match on
|
||||
it for the wire-writing loop.
|
||||
|
||||
Pick the cleaner option. Option 1 keeps `dispatch_requested` returning a
|
||||
`ResponseEnvelope` for the Once path but needs a separate streaming entry point
|
||||
(e.g., `dispatch_requested_streaming` returning `ResponseStream`). Option 2
|
||||
unifies the dispatch entry but changes the return type. The spec frames it as
|
||||
"branches on `op_type`" in `handle_stream`, suggesting the branch lives in the
|
||||
dispatch layer. Document the choice.
|
||||
|
||||
### Streaming dispatch path
|
||||
|
||||
For a `Subscription` op:
|
||||
|
||||
```rust
|
||||
// In dispatch_requested (or a new dispatch_requested_streaming):
|
||||
let context = self.build_root_context(...);
|
||||
// deadline: None for subscriptions (unbounded — ADR-049 §6, call-protocol Timeouts)
|
||||
// The build_root_context sets a 30s deadline; for the streaming path, set
|
||||
// deadline to None AFTER construction (or pass a flag). The spec says
|
||||
// "deadline: None for subscriptions (unbounded)".
|
||||
let stream = self.registry.invoke_streaming(&operation_name, input, context);
|
||||
stream // ResponseStream — pumped by handle_stream
|
||||
```
|
||||
|
||||
### handle_stream streaming pump
|
||||
|
||||
In `handle_stream`, after dispatching, if the result is a stream:
|
||||
|
||||
```rust
|
||||
// Read the ResponseStream, write each envelope as an EventEnvelope frame
|
||||
let mut stream = tokio_stream_into_response_stream(...); // or use StreamExt
|
||||
while let Some(envelope) = stream.next().await {
|
||||
let event: EventEnvelope = envelope.into();
|
||||
if let Err(err) = writer.write_frame(&event).await {
|
||||
warn!(error = %err, "failed to write streaming frame; closing stream");
|
||||
break;
|
||||
}
|
||||
// If the envelope was an error (Err result), the stream ends after it
|
||||
// (the StreamingHandler's contract: Err is terminal). The stream's own
|
||||
// end (None) triggers call.completed below.
|
||||
}
|
||||
// Natural stream end → write call.completed
|
||||
let completed = EventEnvelope::completed(&request_id);
|
||||
if let Err(err) = writer.write_frame(&completed).await {
|
||||
warn!(error = %err, "failed to write call.completed");
|
||||
}
|
||||
```
|
||||
|
||||
The `ResponseEnvelope → EventEnvelope` conversion (`into()`) already exists and
|
||||
produces `call.responded` for `Ok` and `call.error` for `Err`. The
|
||||
`call.completed` frame is written once when the stream ends naturally (not on
|
||||
error — an `Err` envelope is terminal, the stream ends after it, and we do NOT
|
||||
write `call.completed` after a `call.error`; the stream's `None` after an error
|
||||
is not a "natural end"). Track whether the last envelope was an error to decide
|
||||
whether to write `call.completed`. Alternatively, the `StreamingHandler`'s
|
||||
contract is: `Err` ends the stream (the handler's stream yields the error then
|
||||
`None`), so after the loop, only write `call.completed` if the stream did not
|
||||
end on an error. Simplest correct approach: write `call.completed` only on
|
||||
natural end (the stream returned `None` without the last item being an `Err`).
|
||||
Track a `last_was_error` flag.
|
||||
|
||||
### deadline: None for subscriptions
|
||||
|
||||
`build_root_context` sets `deadline: Some(now + 30s)`. For the streaming path,
|
||||
the spec says `deadline: None` (unbounded — subscriptions are long-running). Set
|
||||
`context.deadline = None` after `build_root_context` for the streaming branch,
|
||||
or add a parameter to `build_root_context`. The deadline bounds the
|
||||
request/response call tree; a subscription has no such bound. Document this.
|
||||
|
||||
### Abort cascade (ADR-016)
|
||||
|
||||
If `call.aborted` arrives for a streaming request ID, the stream is dropped
|
||||
(Rust `Drop` releases the handler's resources). The existing `handle_abort`
|
||||
path already removes the pending entry and cascades. For the streaming branch,
|
||||
the stream future being dropped (when the `handle_stream` task is cancelled or
|
||||
the `call.aborted` is processed) releases the handler's resources via `Drop`.
|
||||
No new abort code is needed — the existing `handle_abort` + the stream's `Drop`
|
||||
handle it. Verify the streaming pump task is cancellable (it's a `tokio::spawn`
|
||||
task; aborting the connection cancels it).
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No client-side changes.** The client `CallConnection::subscribe()` already
|
||||
works (it reads `call.responded` events until `call.completed`). This task is
|
||||
server-side only.
|
||||
- **No gateway changes.** `GatewayDispatch::invoke_streaming` is
|
||||
`http/gateway/invoke-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `dispatch_requested` (or a new `dispatch_requested_streaming`) branches on
|
||||
`op_type`: `Subscription` → `invoke_streaming()`, `Query`/`Mutation` →
|
||||
`invoke()` (existing)
|
||||
- [ ] `handle_stream` pumps the `ResponseStream` for the streaming branch:
|
||||
each `ResponseEnvelope` → `EventEnvelope` frame
|
||||
- [ ] Natural stream end → `call.completed` frame written
|
||||
- [ ] `Err` envelope → `call.error` frame written, stream ends after it (no
|
||||
`call.completed` after an error)
|
||||
- [ ] `deadline: None` for the streaming branch (unbounded subscriptions)
|
||||
- [ ] Abort: `call.aborted` for a streaming request drops the stream (Drop
|
||||
releases resources; existing `handle_abort` handles the pending entry)
|
||||
- [ ] Existing `Query`/`Mutation` dispatch path unchanged (one
|
||||
`call.responded`/`call.error` frame, no `call.completed`)
|
||||
- [ ] Unit test: `Subscription` op dispatch → multiple `call.responded` frames
|
||||
+ `call.completed` on stream end
|
||||
- [ ] Unit test: `Subscription` op handler yields `Err` → one `call.error`
|
||||
frame, no `call.completed` after
|
||||
- [ ] Unit test: `Query` op dispatch unchanged (one frame, no `call.completed`)
|
||||
- [ ] Unit test: `call.aborted` for streaming request → stream dropped
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-call` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §6 (server-side dispatch branches on op_type)
|
||||
- docs/architecture/crates/call/call-protocol.md — §CallAdapter Stream Handling (streaming branch: invoke_streaming → write each → call.completed; deadline: None; abort cascade)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop on abort)
|
||||
|
||||
## Notes
|
||||
|
||||
> The streaming pump is a straightforward `while let Some(envelope) = stream.next().await`
|
||||
> loop — not a complex abstraction. The tricky part is the `call.completed`
|
||||
> semantics: write it on natural stream end, NOT after an `Err` (which is
|
||||
> terminal). Track whether the last envelope was an error. `deadline: None` for
|
||||
> subscriptions is a spec requirement — the 30s request/response deadline does
|
||||
> not bound a long-running subscription. The abort cascade needs no new code:
|
||||
> dropping the stream future (via task cancellation or `handle_abort`) releases
|
||||
> the handler's resources through Rust's `Drop`. Pick the dispatch-entry shape
|
||||
> (separate streaming method vs unified enum return) and document it — the spec
|
||||
> frames it as a branch in `handle_stream`, so the branch should be visible there.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
170
tasks/call/registry/invoke-streaming.md
Normal file
170
tasks/call/registry/invoke-streaming.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
id: call/registry/invoke-streaming
|
||||
name: Implement OperationRegistry::invoke_streaming() returning ResponseStream
|
||||
status: pending
|
||||
depends_on: [call/registry/streaming-handler-handlerkind]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add `OperationRegistry::invoke_streaming()` — the streaming dispatch path that
|
||||
`Subscription` operations use. This is the counterpart to `invoke()`: same
|
||||
visibility + ACL checks, then dispatches to the `StreamingHandler` and returns
|
||||
the `ResponseStream`. Pre-handler errors (not-found, forbidden,
|
||||
`INVALID_OPERATION_TYPE` for a non-Subscription op) yield a single error
|
||||
`ResponseEnvelope` and end the stream.
|
||||
|
||||
This task depends on `call/registry/streaming-handler-handlerkind` (which
|
||||
introduces `HandlerKind::Stream` and the `ResponseStream` alias). It adds only
|
||||
the `invoke_streaming()` method — no other changes.
|
||||
|
||||
### invoke_streaming()
|
||||
|
||||
```rust
|
||||
use futures::stream::{self, StreamExt};
|
||||
|
||||
impl OperationRegistry {
|
||||
/// Dispatch a Subscription operation. Returns a stream of
|
||||
/// ResponseEnvelopes. Pre-handler errors (not-found, forbidden,
|
||||
/// INVALID_OPERATION_TYPE for a non-Subscription op) yield a single
|
||||
/// error ResponseEnvelope and end the stream.
|
||||
pub fn invoke_streaming(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
context: OperationContext,
|
||||
) -> ResponseStream {
|
||||
let request_id = context.request_id.clone();
|
||||
|
||||
// 1. Look up registration
|
||||
let registration = match self.operations.get(name) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::not_found(request_id, name)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Visibility check (same as invoke)
|
||||
if registration.spec.visibility == Visibility::Internal && !context.internal {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::not_found(request_id, name)
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. ACL check (same as invoke)
|
||||
let acl = ®istration.spec.access_control;
|
||||
let identity = if context.internal {
|
||||
context.handler_identity.as_ref().and_then(|ca| ca.as_identity())
|
||||
} else {
|
||||
context.identity.clone()
|
||||
};
|
||||
if let AccessResult::Forbidden(message) = acl.check(identity.as_ref()) {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::forbidden(request_id, message)
|
||||
}));
|
||||
}
|
||||
|
||||
// 4. HandlerKind check — must be Stream for invoke_streaming
|
||||
let streaming_handler = match ®istration.handler {
|
||||
HandlerKind::Stream(h) => Arc::clone(h),
|
||||
HandlerKind::Once(_) => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_streaming() called on a Query/Mutation op; use invoke()"
|
||||
),
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Dispatch — the handler returns the stream
|
||||
streaming_handler(input, context)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The visibility + ACL checks are **identical** to `invoke()` — extract them into
|
||||
a private helper if it reduces duplication, but the spec requires the security
|
||||
axis to be provably identical between `invoke()` and `invoke_streaming()`. The
|
||||
two methods diverge only on the return shape (single envelope vs stream) and
|
||||
the handler-kind guard (Once vs Stream).
|
||||
|
||||
### Pre-handler errors as single-item streams
|
||||
|
||||
A pre-handler error (not-found, forbidden, wrong kind) produces a
|
||||
`ResponseStream` that yields exactly one error `ResponseEnvelope` and then ends.
|
||||
This matches the single-response path's behavior, just on a stream — the caller
|
||||
(`Dispatcher::handle_stream` streaming branch, `GatewayDispatch::invoke_streaming`)
|
||||
drains the stream and writes frames; a one-item error stream produces one
|
||||
`call.error` frame and closes.
|
||||
|
||||
Use `futures::stream::once(async move { ... })` to build these single-item
|
||||
streams. The error envelope carries the `request_id` from the context.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `OperationEnv::invoke_streaming()`.** Composition stays
|
||||
request/response-only (ADR-049). `OperationEnv::invoke()` errors on
|
||||
`Subscription` (handled in `streaming-handler-handlerkind` via the
|
||||
`HandlerKind::Stream` match in `LocalOperationEnv` → `registry.invoke()` and
|
||||
`OverlayOperationEnv` direct match). No streaming variant is added to the
|
||||
trait.
|
||||
- **No dispatch-loop wiring.** `Dispatcher::handle_stream` streaming branch is
|
||||
`call/protocol/dispatch-streaming-branch`.
|
||||
- **No gateway wiring.** `GatewayDispatch::invoke_streaming` is
|
||||
`http/gateway/invoke-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `OperationRegistry::invoke_streaming()` method exists
|
||||
- [ ] Returns `ResponseStream` (`Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>`)
|
||||
- [ ] Not-found op → single-item stream with `NOT_FOUND` error envelope, then ends
|
||||
- [ ] Internal op from external call → single-item stream with `NOT_FOUND`, then ends
|
||||
- [ ] ACL denied → single-item stream with `FORBIDDEN`, then ends
|
||||
- [ ] `HandlerKind::Once` op (Query/Mutation) → single-item stream with
|
||||
`INVALID_OPERATION_TYPE`, then ends
|
||||
- [ ] `HandlerKind::Stream` op (Subscription) → dispatches the `StreamingHandler`,
|
||||
returns its stream
|
||||
- [ ] Visibility + ACL checks identical to `invoke()` (same authority switch:
|
||||
internal → handler_identity, external → identity)
|
||||
- [ ] Unit test: `invoke_streaming()` on a registered `Subscription` op yields
|
||||
the handler's stream items
|
||||
- [ ] Unit test: `invoke_streaming()` on unknown op yields one `NOT_FOUND` then ends
|
||||
- [ ] Unit test: `invoke_streaming()` on a `Query` op yields one
|
||||
`INVALID_OPERATION_TYPE` then ends
|
||||
- [ ] Unit test: `invoke_streaming()` on Internal op from external context yields
|
||||
one `NOT_FOUND` then ends
|
||||
- [ ] Unit test: `invoke_streaming()` ACL denied yields one `FORBIDDEN` then ends
|
||||
- [ ] Unit test: `invoke_streaming()` internal call uses handler_identity for ACL
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-call` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §3 (invoke_streaming), §4 (invoke errors on Subscription), §5 (OperationEnv request/response-only)
|
||||
- docs/architecture/crates/call/operation-registry.md — §OperationRegistry (invoke_streaming signature, pre-handler errors as single-item streams)
|
||||
|
||||
## Notes
|
||||
|
||||
> The visibility + ACL checks MUST be identical to `invoke()` — the spec calls
|
||||
> this out explicitly: "invoke_streaming() performs the same visibility + ACL
|
||||
> checks as invoke()". Extract a shared helper if it helps, but the security
|
||||
> axis must be provably identical. Pre-handler errors become single-item streams
|
||||
> (one error envelope, then end) — this matches the single-response path's
|
||||
> behavior, just on a stream. Do NOT add `OperationEnv::invoke_streaming()` —
|
||||
> composition is request/response-only by design (ADR-049 §5); stream
|
||||
> composition is a handler-level concern. The `futures` crate's `stream::once`
|
||||
> and `StreamExt` are the tools for building single-item streams.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
256
tasks/call/registry/streaming-handler-handlerkind.md
Normal file
256
tasks/call/registry/streaming-handler-handlerkind.md
Normal file
@@ -0,0 +1,256 @@
|
||||
---
|
||||
id: call/registry/streaming-handler-handlerkind
|
||||
name: Introduce StreamingHandler, HandlerKind, ResponseStream types and migrate HandlerRegistration to HandlerKind
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
ADR-049 restores the streaming handler path that the Rust port dropped when it
|
||||
collapsed the TS `OperationHandler` / `SubscriptionHandler` union into a single
|
||||
`Handler`. This task introduces the new types (`StreamingHandler`, `HandlerKind`,
|
||||
`ResponseStream`, `make_streaming_handler`), adds the `INVALID_OPERATION_TYPE`
|
||||
protocol error code, changes `HandlerRegistration.handler` from `Handler` to
|
||||
`HandlerKind`, updates the builder to absorb the wrapping, adds registration-time
|
||||
validation, updates `invoke()` to error on `Stream`, updates the overlay env to
|
||||
match on `HandlerKind`, and migrates **every existing construction site** to wrap
|
||||
in `HandlerKind::Once`.
|
||||
|
||||
This is the foundational breaking change — all downstream streaming tasks depend
|
||||
on it. It is broad in surface area (touches `registration.rs`, `wire.rs`,
|
||||
`connection.rs`, and every test/adapter that constructs a `HandlerRegistration`)
|
||||
but each individual change is mechanical. The goal: after this task, the codebase
|
||||
compiles with two handler kinds, `Query`/`Mutation` ops work exactly as before
|
||||
(wrapped in `HandlerKind::Once`), and `Subscription` ops are rejected by `invoke()`
|
||||
with `INVALID_OPERATION_TYPE` (the streaming dispatch path `invoke_streaming()`
|
||||
is added in `call/registry/invoke-streaming`).
|
||||
|
||||
### New types (registration.rs)
|
||||
|
||||
```rust
|
||||
use futures::stream::Stream;
|
||||
|
||||
/// Streaming handler — Subscription operations. Returns a stream of
|
||||
/// ResponseEnvelopes: each Ok(value) → call.responded, an Err → call.error
|
||||
/// (terminal — stream ends), natural stream end → call.completed.
|
||||
pub type StreamingHandler = Arc<
|
||||
dyn Fn(Value, OperationContext)
|
||||
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
/// Type alias for the boxed stream shape used by `invoke_streaming()` and
|
||||
/// `StreamingHandler` return values. `futures::stream::BoxStream<'static, T>`
|
||||
/// = `Pin<Box<dyn Stream<Item = T> + Send>>` — the concrete library is a
|
||||
/// two-way-door implementation detail (ADR-049); the alias exists so the two
|
||||
/// spellings refer to the same type.
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
|
||||
|
||||
/// Which dispatch path a handler uses — locked by ADR-049. Validated against
|
||||
/// `spec.op_type` at registration: Query/Mutation → Once; Subscription → Stream.
|
||||
/// Mismatch is a startup error.
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
}
|
||||
```
|
||||
|
||||
`make_streaming_handler()` helper (analogue of `make_handler()`):
|
||||
|
||||
```rust
|
||||
pub fn make_streaming_handler<S, St>(f: S) -> StreamingHandler
|
||||
where
|
||||
S: Fn(Value, OperationContext) -> St + Send + Sync + 'static,
|
||||
St: Stream<Item = ResponseEnvelope> + Send + 'static,
|
||||
{
|
||||
Arc::new(move |input, context| Box::pin(f(input, context)))
|
||||
}
|
||||
```
|
||||
|
||||
### INVALID_OPERATION_TYPE error code (wire.rs)
|
||||
|
||||
Add the sixth protocol-level error code to `CallError`:
|
||||
|
||||
```rust
|
||||
pub fn invalid_operation_type(message: impl Into<String>) -> Self {
|
||||
Self::new("INVALID_OPERATION_TYPE", message, false)
|
||||
}
|
||||
```
|
||||
|
||||
`retryable: false`, `details: None`. This is a permanent client-side programming
|
||||
error (wrong dispatch path for the operation's type), not a transient failure.
|
||||
Clients should treat unknown codes as `INTERNAL` with `retryable: false` (the
|
||||
existing rule); `INVALID_OPERATION_TYPE` is distinct from `INVALID_INPUT` (schema
|
||||
mismatch) and `INTERNAL` (handler failure).
|
||||
|
||||
### HandlerRegistration.handler → HandlerKind
|
||||
|
||||
```rust
|
||||
pub struct HandlerRegistration {
|
||||
pub spec: OperationSpec,
|
||||
pub handler: HandlerKind, // was: Handler
|
||||
pub provenance: OperationProvenance,
|
||||
pub composition_authority: Option<CompositionAuthority>,
|
||||
pub scoped_env: Option<ScopedPeerEnv>,
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
```
|
||||
|
||||
`HandlerRegistration::new()` takes `handler: HandlerKind` (callers wrap in
|
||||
`HandlerKind::Once(...)` or `HandlerKind::Stream(...)`).
|
||||
|
||||
### Builder absorbs HandlerKind wrapping
|
||||
|
||||
The builder inspects `spec.op_type` and wraps automatically — `.with_local()`
|
||||
and `.with_leaf()` / `.with_leaf_provenance()` take the raw `Handler` (for
|
||||
Query/Mutation) and wrap it in `HandlerKind::Once`. For Subscription ops, add a
|
||||
parallel method pair (`.with_local_streaming()` / `.with_leaf_streaming()`) that
|
||||
takes a `StreamingHandler` and wraps in `HandlerKind::Stream`. The builder
|
||||
validates `handler` kind matches `spec.op_type` and reports mismatch as a
|
||||
startup error.
|
||||
|
||||
The two-method-pair approach is preferred over a typed enum input because it
|
||||
keeps the common case (Query/Mutation, `Handler`) on the existing signatures
|
||||
and makes the streaming case explicit at the call site. Document this choice.
|
||||
|
||||
### register() validation
|
||||
|
||||
`OperationRegistry::register()` validates that `handler` is the right
|
||||
`HandlerKind` for `spec.op_type`:
|
||||
|
||||
- `Query` / `Mutation` → `HandlerKind::Once`
|
||||
- `Subscription` → `HandlerKind::Stream`
|
||||
|
||||
Mismatch is a startup error. Change `register()` to return `Result<(), String>`
|
||||
(preferred — startup errors should be explicit, not panics) with a clear message
|
||||
(`"handler kind mismatch: {op_type} requires HandlerKind::{Once|Stream}"`).
|
||||
Update all `register()` call sites to handle the Result (the builder's `store()`
|
||||
and tests). Alternatively panic with a clear message — but `Result` is cleaner
|
||||
for a startup error and matches the `AdapterError` pattern used elsewhere.
|
||||
|
||||
### invoke() errors on Stream
|
||||
|
||||
`OperationRegistry::invoke()` matches on `registration.handler`:
|
||||
|
||||
- `HandlerKind::Once(handler)` → existing dispatch path (unchanged)
|
||||
- `HandlerKind::Stream(_)` → return `ResponseEnvelope::error(request_id,
|
||||
CallError::invalid_operation_type("invoke() called on a Subscription op;
|
||||
use invoke_streaming()"))`
|
||||
|
||||
This is the guard that prevents a streaming op from being silently truncated
|
||||
through the request/response path. The `invoke_streaming()` method itself is
|
||||
added in `call/registry/invoke-streaming`.
|
||||
|
||||
### OverlayOperationEnv (connection.rs)
|
||||
|
||||
`OverlayOperationEnv::invoke_with_policy` dispatches directly (it does NOT call
|
||||
`registry.invoke()` — it reads the handler from the overlay and calls it). After
|
||||
the type change, `registration.handler` is `HandlerKind`, so the env must match:
|
||||
|
||||
- `HandlerKind::Once(handler)` → `handler(input, context).await` (existing path)
|
||||
- `HandlerKind::Stream(_)` → return `ResponseEnvelope::error(parent.request_id,
|
||||
CallError::invalid_operation_type("OperationEnv::invoke() called on a
|
||||
Subscription op; composition is request/response-only"))`
|
||||
|
||||
`LocalOperationEnv` calls `self.registry.invoke()` which already errors on
|
||||
`Stream` — no change needed there. `PeerCompositeEnv` delegates to
|
||||
session/connection/base envs — no change needed there either.
|
||||
|
||||
### Migration of existing construction sites
|
||||
|
||||
Every site that constructs `HandlerRegistration::new(spec, handler, ...)` must
|
||||
wrap `handler` in `HandlerKind::Once(handler)`. This is mechanical. Sites
|
||||
include (non-exhaustive — find them all with a grep for `HandlerRegistration::new`):
|
||||
|
||||
- `crates/alknet-call/src/registry/registration.rs` (tests)
|
||||
- `crates/alknet-call/src/registry/env.rs` (tests)
|
||||
- `crates/alknet-call/src/registry/discovery.rs` (`services_list_handler`,
|
||||
`services_schema_handler` construction — these are `Query` ops)
|
||||
- `crates/alknet-call/src/protocol/dispatch.rs` (tests)
|
||||
- `crates/alknet-call/src/protocol/connection.rs` (tests,
|
||||
`imported_registration` helper)
|
||||
- `crates/alknet-call/src/client/from_call.rs` (`build_bundles`,
|
||||
`make_forwarding_handler`, tests)
|
||||
- `crates/alknet-http/src/gateway/dispatch.rs` (tests)
|
||||
- `crates/alknet-http/src/server/gateway_routes.rs` (tests)
|
||||
- `crates/alknet-http/src/adapters/from_openapi.rs` (`build_registration`)
|
||||
- `crates/alknet-http/src/adapters/from_mcp/mod.rs` (`build_registration`)
|
||||
|
||||
The builder sites (`with_local`, `with_leaf`, `with_leaf_provenance`, `with`)
|
||||
are updated by the builder-absorbs-wrapping change above — their callers pass
|
||||
raw `Handler` and the builder wraps. Direct `HandlerRegistration::new()` calls
|
||||
need the explicit `HandlerKind::Once(...)` wrap.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `StreamingHandler` type alias in `registration.rs`
|
||||
- [ ] `ResponseStream` type alias (`Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>`)
|
||||
- [ ] `HandlerKind` enum with `Once(Handler)` and `Stream(StreamingHandler)` variants
|
||||
- [ ] `make_streaming_handler()` helper compiles and works
|
||||
- [ ] `CallError::invalid_operation_type()` constructor in `wire.rs`
|
||||
- [ ] `HandlerRegistration.handler` field is `HandlerKind` (not `Handler`)
|
||||
- [ ] `HandlerRegistration::new()` takes `HandlerKind`
|
||||
- [ ] Builder `with_local` / `with_leaf` / `with_leaf_provenance` wrap `Handler` in
|
||||
`HandlerKind::Once` for Query/Mutation
|
||||
- [ ] Builder `with_local_streaming` / `with_leaf_streaming` wrap `StreamingHandler`
|
||||
in `HandlerKind::Stream` for Subscription
|
||||
- [ ] Builder validates `handler` kind matches `spec.op_type` — mismatch is a
|
||||
startup error
|
||||
- [ ] `OperationRegistry::register()` validates `HandlerKind` matches `op_type`
|
||||
(returns `Result<(), String>` or panics with clear message)
|
||||
- [ ] `OperationRegistry::invoke()` dispatches `HandlerKind::Once` (existing path)
|
||||
- [ ] `OperationRegistry::invoke()` returns `INVALID_OPERATION_TYPE` for
|
||||
`HandlerKind::Stream`
|
||||
- [ ] `OverlayOperationEnv::invoke_with_policy` matches on `HandlerKind`:
|
||||
`Once` → dispatch, `Stream` → `INVALID_OPERATION_TYPE`
|
||||
- [ ] `LocalOperationEnv` propagates `INVALID_OPERATION_TYPE` via `registry.invoke()`
|
||||
(no code change needed — verify)
|
||||
- [ ] All existing `HandlerRegistration::new()` call sites wrap in
|
||||
`HandlerKind::Once(...)`
|
||||
- [ ] All existing builder call sites compile (builder absorbs wrapping)
|
||||
- [ ] Unit test: `invoke()` on a `Subscription` op (registered with
|
||||
`HandlerKind::Stream`) returns `INVALID_OPERATION_TYPE`
|
||||
- [ ] Unit test: `invoke()` on a `Query` op (registered with `HandlerKind::Once`)
|
||||
dispatches normally
|
||||
- [ ] Unit test: `register()` rejects `HandlerKind::Once` for a `Subscription` spec
|
||||
- [ ] Unit test: `register()` rejects `HandlerKind::Stream` for a `Query` spec
|
||||
- [ ] Unit test: `OverlayOperationEnv::invoke()` on a `Stream`-kind overlay op
|
||||
returns `INVALID_OPERATION_TYPE`
|
||||
- [ ] Unit test: `make_streaming_handler` produces a working `StreamingHandler`
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-call -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 (the decision)
|
||||
- docs/architecture/crates/call/operation-registry.md — §Handler (StreamingHandler, HandlerKind, ResponseStream, make_streaming_handler), §OperationRegistry (register validation, invoke errors on Stream), §HandlerRegistration
|
||||
- docs/architecture/crates/call/call-protocol.md — §call.error Payload (INVALID_OPERATION_TYPE protocol code)
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (amended: six protocol codes)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the foundational breaking change. The `HandlerRegistration.handler`
|
||||
> type flip from `Handler` to `HandlerKind` ripples to every construction site,
|
||||
> but each change is mechanical (`Handler` → `HandlerKind::Once(handler)`). The
|
||||
> builder absorbs the wrapping for the common case. The load-bearing parts are:
|
||||
> (1) `register()` validation catches kind/op_type mismatch at startup, (2)
|
||||
> `invoke()` errors on `Stream` (the guard that prevents silent truncation), (3)
|
||||
> `OverlayOperationEnv` matches on `HandlerKind` (it dispatches directly, not via
|
||||
> `registry.invoke()`). `LocalOperationEnv` needs no change — it delegates to
|
||||
> `registry.invoke()` which handles it. Do NOT add `invoke_streaming()` in this
|
||||
> task — that's `call/registry/invoke-streaming`. The `futures` crate is already
|
||||
> a dependency of `alknet-call`. The two-method-pair builder API
|
||||
> (`with_local`/`with_local_streaming`) is preferred over a typed enum input —
|
||||
> it keeps the common case on existing signatures and makes streaming explicit.
|
||||
|
||||
## Summary
|
||||
|
||||
> Introduced StreamingHandler/ResponseStream type aliases and HandlerKind enum (Once|Stream) + make_streaming_handler() helper in registration.rs; added CallError::invalid_operation_type() (sixth protocol code, retryable: false) in wire.rs; flipped HandlerRegistration.handler to HandlerKind and changed new() signature; builder absorbs wrapping (with_local/with_leaf wrap Handler in Once for Query/Mutation, new with_local_streaming/with_leaf_streaming take StreamingHandler and wrap in Stream for Subscription) with kind/op_type mismatch validation; OperationRegistry::register() now returns Result<(), String> with clear mismatch message; invoke() errors on HandlerKind::Stream with INVALID_OPERATION_TYPE; OverlayOperationEnv::invoke_with_policy matches on HandlerKind (Stream -> INVALID_OPERATION_TYPE); migrated all ~95 HandlerRegistration::new() call sites to wrap in HandlerKind::Once(handler); updated two websocket subscription tests to expect INVALID_OPERATION_TYPE; added unit tests for invoke/register validation, make_streaming_handler, and overlay Stream-kind rejection. All verification passes (build, clippy -D warnings, test, fmt --check) for alknet-call + alknet-http.
|
||||
243
tasks/http/adapters/from-openapi-sse-streaming.md
Normal file
243
tasks/http/adapters/from-openapi-sse-streaming.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
id: http/adapters/from-openapi-sse-streaming
|
||||
name: Implement from_openapi Subscription forwarding as StreamingHandler (SSE response → BoxStream<ResponseEnvelope>)
|
||||
status: pending
|
||||
depends_on: [call/registry/streaming-handler-handlerkind]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Branch `from_openapi`'s forwarding handler construction on `op_type` so that a
|
||||
`Subscription` op (detected via `text/event-stream` response content type)
|
||||
registers a `StreamingHandler` (`HandlerKind::Stream`) that streams the SSE
|
||||
response chunks as `ResponseEnvelope::ok()` items. `Query`/`Mutation` ops keep
|
||||
the existing `Handler` (`HandlerKind::Once`) that returns a single
|
||||
`ResponseEnvelope`. This closes the gap where a `from_openapi`-imported
|
||||
`Subscription` returned only the last SSE event.
|
||||
|
||||
This task depends on `call/registry/streaming-handler-handlerkind` (which
|
||||
introduces `HandlerKind::Stream` and `make_streaming_handler`). The existing
|
||||
`from_openapi` code already detects `Subscription` (`detect_op_type` checks for
|
||||
`text/event-stream`) and has an SSE parser (`parse_sse_frames`); this task
|
||||
rewires the subscription path from "collect all events, return last" to "stream
|
||||
events as they arrive".
|
||||
|
||||
### The branch in build_registration
|
||||
|
||||
`build_registration` currently always builds a `Handler` (via `make_handler`) and
|
||||
wraps in `HandlerKind::Once` (after `streaming-handler-handlerkind`). Branch on
|
||||
`op_type`:
|
||||
|
||||
- `Query` / `Mutation` → existing `make_handler` + `forward()` (single response),
|
||||
`HandlerKind::Once`
|
||||
- `Subscription` → new `make_streaming_handler` + `forward_stream()` (SSE
|
||||
streaming), `HandlerKind::Stream`
|
||||
|
||||
The `op_type` is already computed by `detect_op_type` and available in
|
||||
`build_registration`. The `HandlerRegistration::new()` call at the end wraps in
|
||||
the right `HandlerKind` based on `op_type`.
|
||||
|
||||
### forward_stream() — the streaming forward function
|
||||
|
||||
```rust
|
||||
async fn forward_stream(
|
||||
http_client: &Arc<SharedHttpClient>,
|
||||
base_url: &str,
|
||||
path_template: &str,
|
||||
method: &str,
|
||||
auth_scheme: &Option<HttpAuthScheme>,
|
||||
default_headers: &HashMap<String, String>,
|
||||
namespace: &str,
|
||||
error_status_codes: &[(u16, String)],
|
||||
input: Value,
|
||||
context: OperationContext,
|
||||
) -> ResponseStream {
|
||||
let request_id = context.request_id.clone();
|
||||
|
||||
// 1. Build the request (same as forward())
|
||||
let (http_method, url, body, headers) = match build_request(...) {
|
||||
Ok(parts) => parts,
|
||||
Err(err) => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, err)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Send with Accept: text/event-stream
|
||||
let request_builder = http_client.client()
|
||||
.request(http_method, url.as_str())
|
||||
.headers(headers)
|
||||
.header(ACCEPT, "text/event-stream");
|
||||
let request_builder = match body.as_ref() {
|
||||
Some(b) => request_builder.body(serde_json::to_string(b).unwrap_or("null".to_string())),
|
||||
None => request_builder,
|
||||
};
|
||||
|
||||
let response: reqwest::Response = match request_builder.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, CallError::internal(format!("HTTP request failed: {err}")))
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Non-2xx → single error envelope, stream ends
|
||||
let code = error_status_codes.iter()
|
||||
.find(|(s, _)| *s == status.as_u16())
|
||||
.map(|(_, c)| c.clone())
|
||||
.unwrap_or_else(|| format!("HTTP_{}", status.as_u16()));
|
||||
let message = format!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, CallError::new(code, message, false))
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Stream the SSE chunks → ResponseEnvelope::ok() per data: frame
|
||||
let request_id_stream = request_id.clone();
|
||||
let sse_stream = response.bytes_stream()
|
||||
.scan(String::new(), move |buffer, chunk_result| {
|
||||
// Parse SSE frames from the chunk, emit each as a ResponseEnvelope::ok()
|
||||
// This is the streaming analogue of stream_subscription()
|
||||
let request_id = request_id_stream.clone();
|
||||
async move {
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
let (events, remaining) = parse_sse_frames(buffer);
|
||||
*buffer = remaining;
|
||||
// Emit each event as a ResponseEnvelope::ok()
|
||||
let envelopes: Vec<ResponseEnvelope> = events.into_iter()
|
||||
.map(|e| {
|
||||
let parsed = if e.data.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(&e.data).unwrap_or(Value::String(e.data.clone()))
|
||||
};
|
||||
ResponseEnvelope::ok(&request_id, parsed)
|
||||
})
|
||||
.collect();
|
||||
Some((envelopes,)) // yield the batch
|
||||
}
|
||||
Err(err) => {
|
||||
let error = CallError::internal(format!("SSE stream error: {err}"));
|
||||
Some(vec![ResponseEnvelope::error(request_id, error)])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.flat_map(|envelopes| stream::iter(envelopes));
|
||||
|
||||
Box::pin(sse_stream)
|
||||
}
|
||||
```
|
||||
|
||||
The exact combinator shape (`scan` + `flat_map`, or a custom `Stream` impl, or
|
||||
`unfold`) is an implementation detail — the contract is: each SSE `data:` frame
|
||||
becomes a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) becomes a single
|
||||
`ResponseEnvelope::error()` and ends the stream; SSE stream end ends the
|
||||
`ResponseStream` (→ `call.completed` on the wire). Reuse the existing
|
||||
`parse_sse_frames` parser — it already handles multi-event buffers, partial
|
||||
trailing lines, comments, multi-line data, BOM.
|
||||
|
||||
### Remove stream_subscription() (the collect-all placeholder)
|
||||
|
||||
The existing `stream_subscription()` collects all SSE events and returns the
|
||||
last one as a single `ResponseEnvelope`. This is the placeholder that
|
||||
truncates. Remove it (or repurpose its SSE-parsing logic into the streaming
|
||||
`forward_stream`). The `parse_sse_frames` function stays (it's reused by
|
||||
`forward_stream`); only the collect-all `stream_subscription` wrapper goes.
|
||||
|
||||
### build_registration wiring
|
||||
|
||||
```rust
|
||||
let handler = if op_type == OperationType::Subscription {
|
||||
// Streaming handler — HandlerKind::Stream
|
||||
let stream_handler = make_streaming_handler(move |input, context| {
|
||||
// clone captured vars
|
||||
async move {
|
||||
forward_stream(&http_client, &base_url, &path_template, &method_upper,
|
||||
&auth_scheme, &default_headers, &namespace, &error_status_codes,
|
||||
input, context).await
|
||||
}
|
||||
});
|
||||
HandlerKind::Stream(stream_handler)
|
||||
} else {
|
||||
// Request/response handler — HandlerKind::Once (existing)
|
||||
let once_handler = make_handler(move |input, context| {
|
||||
// clone captured vars
|
||||
async move {
|
||||
forward(&http_client, &base_url, &path_template, &method_upper,
|
||||
&auth_scheme, &default_headers, &namespace, &error_status_codes,
|
||||
op_type, input, context).await
|
||||
}
|
||||
});
|
||||
HandlerKind::Once(once_handler)
|
||||
};
|
||||
|
||||
HandlerRegistration::new(spec, handler, OperationProvenance::FromOpenAPI, None, None, capabilities)
|
||||
```
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `from_mcp` changes.** `from_mcp` handlers are always `HandlerKind::Once`
|
||||
(MCP tools are request/response — ADR-041; ADR-049 confirms this is unchanged).
|
||||
- **No gateway changes.** The gateway `/subscribe` SSE path is
|
||||
`http/server/subscribe-sse-streaming`.
|
||||
- **No `OperationRegistry` changes.** `invoke_streaming()` is provided by
|
||||
`call/registry/invoke-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `build_registration` branches on `op_type`: `Subscription` →
|
||||
`HandlerKind::Stream` (streaming forward), `Query`/`Mutation` →
|
||||
`HandlerKind::Once` (existing forward)
|
||||
- [ ] `forward_stream()` streams SSE chunks as `ResponseEnvelope::ok()` items
|
||||
- [ ] Each SSE `data:` frame → one `ResponseEnvelope::ok()`
|
||||
- [ ] HTTP error (non-2xx) → single `ResponseEnvelope::error()`, stream ends
|
||||
- [ ] SSE stream end → `ResponseStream` ends (→ `call.completed` on wire)
|
||||
- [ ] `parse_sse_frames` reused (multi-event, partial trailing, comments,
|
||||
multi-line data, BOM — all handled)
|
||||
- [ ] `stream_subscription()` (collect-all placeholder) removed or repurposed
|
||||
- [ ] `Query`/`Mutation` forwarding unchanged (existing `forward()` path)
|
||||
- [ ] `Accept: text/event-stream` header sent for Subscription requests
|
||||
- [ ] Unit test: `Subscription` op registration is `HandlerKind::Stream`
|
||||
- [ ] Unit test: `Query` op registration is `HandlerKind::Once` (unchanged)
|
||||
- [ ] Integration test: `Subscription` forwarding streams multiple
|
||||
`ResponseEnvelope::ok()` items from an SSE server (one per `data:` frame)
|
||||
- [ ] Integration test: `Subscription` forwarding on HTTP error → one
|
||||
`ResponseEnvelope::error()`, stream ends
|
||||
- [ ] Integration test: `Query` forwarding unchanged (single response)
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §9 (from_openapi SSE forwarding)
|
||||
- docs/architecture/crates/http/http-adapters.md — §Forwarding handler (Subscription → HandlerKind::Stream, SSE → BoxStream)
|
||||
- docs/architecture/crates/http/http-mcp.md — from_mcp handlers always HandlerKind::Once (unchanged)
|
||||
|
||||
## Notes
|
||||
|
||||
> The existing `stream_subscription()` is the placeholder that truncates — it
|
||||
> collects all SSE events and returns the last. Replace it with `forward_stream()`
|
||||
> that yields each SSE event as a stream item. Reuse `parse_sse_frames` (it's
|
||||
> already correct for multi-event buffers, partial lines, comments, BOM). The
|
||||
> combinator shape (`scan` + `flat_map`, `unfold`, or custom `Stream`) is an
|
||||
> implementation detail — the contract is one `ResponseEnvelope::ok()` per
|
||||
> `data:` frame, error on HTTP failure, end on SSE close. `from_mcp` is
|
||||
> unchanged — MCP tools are request/response (ADR-041), always
|
||||
> `HandlerKind::Once`. The `futures` crate's `StreamExt::scan` / `flat_map` /
|
||||
> `unfold` are the likely tools.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: http/adapters/to-openapi
|
||||
name: Implement to_openapi gateway projection (5-endpoint OpenAPI doc, info.version semver, ADR-042/045)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [http/server/gateway-endpoints, http/gateway/gateway-dispatch-spine]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
@@ -185,4 +185,11 @@ out of scope.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> Implemented to_openapi(registry: &OperationRegistry) -> OpenAPISpec in src/adapters/
|
||||
> to_openapi.rs — pure projection generating fixed 5-endpoint gateway doc (/search,
|
||||
> /schema, /call, /batch, /subscribe) with info.version = 1.0.0 (ADR-045). /call responses
|
||||
> carry protocol-level errors (400/401/403/404/500/504) + operation-level errors from
|
||||
> registry error_schemas mapped by http_status (ADR-023). Per-caller operation surface
|
||||
> NOT preloaded (discovered via /search, ADR-042). /subscribe response is text/event-stream.
|
||||
> Wired GET /openapi.json in adapter.rs replacing placeholder 501. 16 new tests. 230
|
||||
> total tests pass. Clippy clean. Formatting fixed during merge.
|
||||
130
tasks/http/gateway/invoke-streaming.md
Normal file
130
tasks/http/gateway/invoke-streaming.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
id: http/gateway/invoke-streaming
|
||||
name: Implement GatewayDispatch::invoke_streaming() returning BoxStream<ResponseEnvelope>
|
||||
status: pending
|
||||
depends_on: [call/registry/invoke-streaming]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add `GatewayDispatch::invoke_streaming()` — the streaming analogue of
|
||||
`invoke()`, returning a `BoxStream<ResponseEnvelope>`. The security invariants
|
||||
are identical to `invoke()`: `internal: false`, `forwarded_for: None`, same
|
||||
capabilities, same `scoped_env`, same ACL check before dispatch. The two methods
|
||||
diverge only on the return shape (stream vs single envelope). The HTTP
|
||||
`/subscribe` handler calls this and pipes the stream to SSE.
|
||||
|
||||
This task depends on `call/registry/invoke-streaming` (which provides
|
||||
`OperationRegistry::invoke_streaming()`). It adds the `GatewayDispatch` method
|
||||
only — the `/subscribe` SSE wiring is `http/server/subscribe-sse-streaming`.
|
||||
|
||||
### invoke_streaming()
|
||||
|
||||
```rust
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
impl GatewayDispatch {
|
||||
/// Invoke a Subscription operation as a wire-ingress caller. The streaming
|
||||
/// analogue of `invoke()`. Security invariants identical to `invoke()`:
|
||||
/// `internal: false`, `forwarded_for: None`, same capabilities, same
|
||||
/// scoped_env, same ACL. Diverges only on return shape (stream vs single
|
||||
/// envelope). Returns a `BoxStream<ResponseEnvelope>`; the `/subscribe`
|
||||
/// handler pipes it to SSE.
|
||||
pub async 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(&request_id, &operation_name, identity);
|
||||
// The registry's invoke_streaming returns ResponseStream
|
||||
// (Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>), which IS
|
||||
// BoxStream<'static, ResponseEnvelope>. Box::pin / BoxStream are the
|
||||
// same type — the alias just spells it out.
|
||||
self.registry.invoke_streaming(&operation_name, input, context)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`build_root_context` is reused unchanged — it constructs the root
|
||||
`OperationContext` with `internal: false`, `forwarded_for: None`, fresh
|
||||
`request_id`, deadline, registration bundle's `composition_authority` /
|
||||
`capabilities` / `scoped_env`. The security axis is provably identical between
|
||||
`invoke()` and `invoke_streaming()` because they share `build_root_context`.
|
||||
|
||||
### deadline: None for streaming
|
||||
|
||||
The spec says `deadline: None` for subscriptions (unbounded). `build_root_context`
|
||||
sets `deadline: Some(now + 30s)`. For the streaming path, set
|
||||
`context.deadline = None` after `build_root_context`, OR add a streaming flag to
|
||||
`build_root_context`. Coordinate with `call/protocol/dispatch-streaming-branch`
|
||||
which has the same concern — extract a shared approach (e.g., a
|
||||
`build_root_context_streaming` variant or a `deadline: None` override). The
|
||||
gateway and the call dispatcher both need `deadline: None` for subscriptions;
|
||||
don't duplicate the logic.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `/subscribe` SSE wiring.** That's `http/server/subscribe-sse-streaming`.
|
||||
- **No `OperationRegistry` changes.** `invoke_streaming()` is provided by
|
||||
`call/registry/invoke-streaming`.
|
||||
- **No `to_mcp` changes.** MCP excludes `Subscription` (ADR-041); `to_mcp` never
|
||||
calls `invoke_streaming()`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `GatewayDispatch::invoke_streaming()` method exists
|
||||
- [ ] Returns `BoxStream<'static, ResponseEnvelope>` (or `ResponseStream` —
|
||||
same type)
|
||||
- [ ] Builds root `OperationContext` via `build_root_context` (same as `invoke()`)
|
||||
- [ ] Root context has `internal: false`, `forwarded_for: None`, fresh
|
||||
`request_id`
|
||||
- [ ] `handler_identity`, `capabilities`, `scoped_env` from registration bundle
|
||||
(same as `invoke()`)
|
||||
- [ ] `deadline: None` for the streaming path (unbounded subscriptions)
|
||||
- [ ] Calls `OperationRegistry::invoke_streaming()`
|
||||
- [ ] Security invariants identical to `invoke()` (shared `build_root_context`)
|
||||
- [ ] Unit test: `invoke_streaming()` on a registered `Subscription` op returns
|
||||
the handler's stream
|
||||
- [ ] Unit test: `invoke_streaming()` on unknown op returns a stream yielding
|
||||
one `NOT_FOUND`
|
||||
- [ ] Unit test: `invoke_streaming()` on Internal op from external returns
|
||||
stream yielding one `NOT_FOUND` (not leaked)
|
||||
- [ ] Unit test: `invoke_streaming()` with `None` identity + restricted op
|
||||
returns stream yielding one `FORBIDDEN`
|
||||
- [ ] Unit test: `invoke_streaming()` on a `Query` op returns stream yielding
|
||||
one `INVALID_OPERATION_TYPE`
|
||||
- [ ] Unit test: `invoke()` (existing) on a `Subscription` op returns
|
||||
`INVALID_OPERATION_TYPE` (verifies the guard from
|
||||
`streaming-handler-handlerkind` holds through the gateway)
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §7 (GatewayDispatch::invoke_streaming)
|
||||
- docs/architecture/crates/http/http-server.md — §Streaming projection (invoke_streaming security invariants identical to invoke)
|
||||
- docs/architecture/crates/call/operation-registry.md — §OperationRegistry (invoke_streaming)
|
||||
|
||||
## Notes
|
||||
|
||||
> The security axis MUST be provably identical between `invoke()` and
|
||||
> `invoke_streaming()` — they share `build_root_context`. The two methods
|
||||
> diverge only on the return shape. `deadline: None` for subscriptions is a
|
||||
> shared concern with `call/protocol/dispatch-streaming-branch` — extract a
|
||||
> shared approach (a streaming-variant of `build_root_context` or a
|
||||
> `deadline: None` override) rather than duplicating. `to_mcp` never calls
|
||||
> `invoke_streaming()` (MCP excludes `Subscription` — ADR-041); do not add
|
||||
> streaming to the MCP gateway. The `futures` crate is already a dependency of
|
||||
> `alknet-http`.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: http/review-http-final
|
||||
name: Final review of alknet-http crate — all components, feature gates, pattern consistency
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [http/review-http, http/review-websocket, http/review-mcp]
|
||||
scope: broad
|
||||
risk: low
|
||||
@@ -176,4 +176,24 @@ WebSocket, MCP) and verifies the crate as a whole.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> Final crate-wide review complete. All 9 checklist areas pass:
|
||||
> 1. Crate structure: Cargo.toml, lib.rs, 5 modules (server/gateway/client/adapters/websocket),
|
||||
> workspace member, workspace path deps for alknet-core + alknet-call.
|
||||
> 2. Feature gate isolation: default = [h2, http1], mcp = [dep:rmcp], h3 ABSENT (ADR-044),
|
||||
> cargo check (default/mcp/no-default) all succeed, MCP code not compiled without mcp.
|
||||
> 3. Dependencies correct: alknet-core, alknet-call, axum, reqwest stack, hyper, rmcp (mcp-gated).
|
||||
> No wtransport/h3. No env-var config.
|
||||
> 4. Cross-cutting: no-env-vars (no std::env::var in any handler), no secret material in responses,
|
||||
> AccessControl sole gate, Internal → NOT_FOUND, error fidelity (HTTP_<status> prefix),
|
||||
> browsers/MCP clients not peers.
|
||||
> 5. Pattern consistency: GatewayDispatch concrete struct (not trait), auth middleware shared,
|
||||
> SharedHttpClient ArcSwap-wrapped, error mapping free function, from_* are OperationAdapter
|
||||
> impls, to_* are pure projections.
|
||||
> 6. ADR conformance: all ADRs (003-048) verified.
|
||||
> 7. Absence verified: no h3/WebTransport, no from_wss, no stdio MCP, no direct-call surface,
|
||||
> no traditional per-operation-paths OpenAPI, no env-var config.
|
||||
> 8. Test coverage: alknet-call 277+2, alknet-http default 230, alknet-http mcp 265+5. All pass.
|
||||
> 9. Build cleanliness: fmt clean, clippy clean (default + mcp + all-targets), build clean.
|
||||
>
|
||||
> One known limitation: /subscribe SSE completes after single event (registry invoke returns
|
||||
> single ResponseEnvelope, no streaming subscription handler yet — research §6 OQ#5).
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: http/review-http
|
||||
name: Review alknet-http server surface + OpenAPI adapters for spec conformance
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [http/server/gateway-endpoints, http/adapters/to-openapi, http/adapters/from-openapi, http/server/healthz-decoy]
|
||||
scope: broad
|
||||
risk: low
|
||||
@@ -163,4 +163,15 @@ core of the crate.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> HTTP server surface + OpenAPI adapters reviewed against all 12 checklist items. All
|
||||
> conformance criteria pass: HttpAdapter (struct, DecoyConfig, ProtocolHandler, axum
|
||||
> over QUIC, h3 not registered), gateway endpoints (5 fixed, no direct-call ADR-047,
|
||||
> flat JSON /call, GatewayDispatch::invoke, Internal → 404, 401/403 split, SSE /subscribe),
|
||||
> error mapping (all codes, HTTP_<status> prefix, 401/403 split, Retry-After), auth
|
||||
> (Bearer-only, shared middleware, ResolvedIdentity, no env vars), /healthz raw + decoy
|
||||
> fallback, to_openapi (5 endpoints, info.version 1.0.0, per-caller via /search, error
|
||||
> fidelity ADR-023), from_openapi (OperationAdapter, no-env-vars, HTTP_<status>, SSE),
|
||||
> SharedHttpClient (ClientWithMiddleware, retry, RetryAfter, ArcSwap), GatewayDispatch
|
||||
> (concrete struct, not trait), security constraints (no secrets in responses, no env
|
||||
> vars, Internal → 404, AccessControl sole gate). 230 default + 265 mcp tests pass.
|
||||
> Clippy clean on both feature configs. Fmt clean.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: http/review-websocket
|
||||
name: Review WebSocket path for ADR-044/048 conformance (native session, no length prefix, browsers-not-peers)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [http/websocket/connection-overlay]
|
||||
scope: moderate
|
||||
risk: low
|
||||
@@ -151,4 +151,14 @@ gateway shape, no length prefix).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> WebSocket path reviewed against all 11 checklist items. All conformance criteria
|
||||
> pass: dispatcher transport abstraction (pub API, non-QUIC CallConnection, no
|
||||
> regressions — 277+2 alknet-call tests), WS upgrade (/alknet/call, Bearer auth, 401,
|
||||
> HTTP/1.1+HTTP/2), framing (binary WS = EventEnvelope, no length prefix, text → close
|
||||
> 1002), dispatch (dispatch_requested, AccessControl gates, FORBIDDEN → call.error,
|
||||
> correlation by id, handle_abort), bidirectionality (hub calls browser ops via overlay),
|
||||
> connection-local overlay (no PeerId, no PeerEntry, overlay_env() routing,
|
||||
> PeerRef::Specific → NOT_FOUND, overlay dies on close), browsers-not-peers rationale,
|
||||
> streaming (native call.responded, no SSE, abort cascade on disconnect), all ADRs
|
||||
> (012/016/024/029/034/044/048), security constraints. 230 tests pass, clippy clean,
|
||||
> fmt clean.
|
||||
156
tasks/http/server/subscribe-sse-streaming.md
Normal file
156
tasks/http/server/subscribe-sse-streaming.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
id: http/server/subscribe-sse-streaming
|
||||
name: Wire /subscribe handler to GatewayDispatch::invoke_streaming() and pipe BoxStream to SSE
|
||||
status: pending
|
||||
depends_on: [http/gateway/invoke-streaming]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Replace the `/subscribe` handler's one-event placeholder
|
||||
(`subscribe_stream_from_envelope`, which calls `GatewayDispatch::invoke()` and
|
||||
wraps the single `ResponseEnvelope` in a one-event SSE stream) with the real
|
||||
streaming path: call `GatewayDispatch::invoke_streaming()` and pipe the
|
||||
`BoxStream<ResponseEnvelope>` to SSE. Each `Ok(value)` → SSE `data:` frame;
|
||||
`Err` → SSE error event + close (terminal); natural stream end → close (normal
|
||||
end, corresponds to `call.completed` on the wire). On `call.aborted` or HTTP
|
||||
client disconnect, drop the stream (Drop releases handler resources, abort
|
||||
cascade runs per ADR-016).
|
||||
|
||||
This task depends on `http/gateway/invoke-streaming` (which provides
|
||||
`GatewayDispatch::invoke_streaming()`). It rewrites `subscribe_handler` and
|
||||
removes the placeholder helpers.
|
||||
|
||||
### subscribe_handler rewrite
|
||||
|
||||
```rust
|
||||
pub(crate) async fn subscribe_handler(
|
||||
State(state): State<GatewayState>,
|
||||
ResolvedIdentity(identity): ResolvedIdentity,
|
||||
Json(request): Json<CallRequest>,
|
||||
) -> Sse<SubscribeStream> {
|
||||
let stream = if is_internal_op(&state.registry, &request.operation) {
|
||||
// Internal ops return NOT_FOUND (don't leak existence) — single error event
|
||||
subscribe_stream_internal_error(request.operation)
|
||||
} else {
|
||||
let dispatch = state.dispatch();
|
||||
let envelope_stream = dispatch
|
||||
.invoke_streaming(identity, &request.operation, request.input)
|
||||
.await;
|
||||
// Pipe the BoxStream<ResponseEnvelope> to SSE frames
|
||||
subscribe_stream_from_envelope_stream(envelope_stream)
|
||||
};
|
||||
Sse::new(stream)
|
||||
}
|
||||
```
|
||||
|
||||
### subscribe_stream_from_envelope_stream
|
||||
|
||||
Map each `ResponseEnvelope` in the `BoxStream` to an SSE `Event`:
|
||||
|
||||
```rust
|
||||
fn subscribe_stream_from_envelope_stream(
|
||||
stream: BoxStream<'static, ResponseEnvelope>,
|
||||
) -> SubscribeStream {
|
||||
Box::pin(stream.map(|envelope| {
|
||||
match envelope.result {
|
||||
Ok(output) => {
|
||||
let data = serde_json::to_string(&output)
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
Ok(Event::default().data(data))
|
||||
}
|
||||
Err(error) => {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
The `Err` case produces an SSE error event — the stream ends after it (the
|
||||
`StreamingHandler`'s contract: `Err` is terminal). The natural stream end
|
||||
(stream yields `None`) closes the SSE stream (axum's `Sse` wrapper handles the
|
||||
close when the underlying stream ends).
|
||||
|
||||
### Remove the placeholder
|
||||
|
||||
Delete `subscribe_stream_from_envelope` (the one-event placeholder) and
|
||||
`envelope_to_sse_stream` (the single-envelope-to-stream helper). The new
|
||||
`subscribe_stream_from_envelope_stream` replaces them. Keep
|
||||
`subscribe_stream_internal_error` (Internal ops still return a single
|
||||
`NOT_FOUND` error event — they don't reach `invoke_streaming()`).
|
||||
|
||||
### Client disconnect / abort
|
||||
|
||||
axum's `Sse` response detects when the HTTP client disconnects (the response
|
||||
writer closes) and drops the stream future. `Drop` releases the handler's
|
||||
resources, and the abort cascade runs per ADR-016. No explicit disconnect
|
||||
handling is needed — Rust's `Drop` + axum's response-drop handle it. Verify the
|
||||
stream is dropped (not leaked) on disconnect.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `GatewayDispatch` changes.** `invoke_streaming()` is provided by
|
||||
`http/gateway/invoke-streaming`.
|
||||
- **No `to_mcp` changes.** MCP has no `/subscribe` equivalent (ADR-041).
|
||||
- **No `from_openapi` changes.** `from_openapi` SSE forwarding is
|
||||
`http/adapters/from-openapi-sse-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `subscribe_handler` calls `GatewayDispatch::invoke_streaming()` (not
|
||||
`invoke()`)
|
||||
- [ ] `subscribe_stream_from_envelope_stream` maps `BoxStream<ResponseEnvelope>`
|
||||
to SSE `Event`s
|
||||
- [ ] `Ok(value)` → SSE `data:` frame with output serialized as JSON
|
||||
- [ ] `Err` → SSE error event (`event: error`) with `CallError` serialized, then
|
||||
stream ends (terminal)
|
||||
- [ ] Natural stream end → SSE stream closes (normal end)
|
||||
- [ ] Internal op → single `NOT_FOUND` error event (unchanged —
|
||||
`subscribe_stream_internal_error` kept)
|
||||
- [ ] Client disconnect → stream dropped (Drop releases resources; abort cascade)
|
||||
- [ ] Placeholder helpers (`subscribe_stream_from_envelope`,
|
||||
`envelope_to_sse_stream`) removed
|
||||
- [ ] `SubscribeStream` type alias still `BoxStream<'static, Result<Event, Infallible>>`
|
||||
- [ ] Unit test: `/subscribe` on a `Subscription` op streams multiple `data:`
|
||||
frames (one per `call.responded`)
|
||||
- [ ] Unit test: `/subscribe` on a `Subscription` op that yields `Err` → one
|
||||
`event:error` frame, then stream closes
|
||||
- [ ] Unit test: `/subscribe` on Internal op → `event:error` with `NOT_FOUND`
|
||||
(unchanged)
|
||||
- [ ] Unit test: `/subscribe` on unknown op → `event:error` with `NOT_FOUND`
|
||||
- [ ] Unit test: `/subscribe` on `Query` op → `event:error` with
|
||||
`INVALID_OPERATION_TYPE` (the guard holds through the gateway)
|
||||
- [ ] Unit test: response `Content-Type` is `text/event-stream`
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §7 (HTTP /subscribe pipes BoxStream to SSE)
|
||||
- docs/architecture/crates/http/http-server.md — §Streaming projection (SSE — the gateway's /subscribe)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop on disconnect/abort)
|
||||
|
||||
## Notes
|
||||
|
||||
> This replaces the one-event placeholder with the real streaming path. The
|
||||
> `Err` envelope is terminal — the SSE stream ends after the error event (no
|
||||
> `data:` frame after an `event:error`). Natural stream end closes the SSE
|
||||
> stream (axum handles the close when the underlying stream ends). Client
|
||||
> disconnect drops the stream future via Rust's `Drop` — no explicit handling
|
||||
> needed. Keep `subscribe_stream_internal_error` (Internal ops return
|
||||
> `NOT_FOUND` without reaching `invoke_streaming()` — they don't leak
|
||||
> existence). The `futures::StreamExt::map` combinator is the tool for mapping
|
||||
> the envelope stream to SSE events.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
210
tasks/review-streaming-impl.md
Normal file
210
tasks/review-streaming-impl.md
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
id: review-streaming-impl
|
||||
name: Review ADR-049 streaming handler implementation for spec conformance and end-to-end correctness
|
||||
status: pending
|
||||
depends_on: [call/protocol/dispatch-streaming-branch, call/client/from-call-streaming-forwarding, http/gateway/invoke-streaming, http/server/subscribe-sse-streaming, http/adapters/from-openapi-sse-streaming]
|
||||
scope: broad
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the ADR-049 streaming handler implementation across `alknet-call` and
|
||||
`alknet-http` for spec conformance, end-to-end correctness, and pattern
|
||||
consistency. This is the quality checkpoint after the streaming handler work —
|
||||
the most significant cross-cutting change since the initial call/http
|
||||
implementation. All five implementation tasks must be complete before this
|
||||
review.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **Type surface conformance** (operation-registry.md §Handler):
|
||||
- `StreamingHandler` type alias matches spec (`Arc<dyn Fn(Value, OperationContext) -> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>> + Send + Sync>`)
|
||||
- `ResponseStream` alias = `Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>` (= `futures::stream::BoxStream<'static, ResponseEnvelope>`)
|
||||
- `HandlerKind` enum with `Once(Handler)` and `Stream(StreamingHandler)` variants
|
||||
- `make_streaming_handler()` helper (analogue of `make_handler()`)
|
||||
- `HandlerRegistration.handler` is `HandlerKind` (not `Handler`)
|
||||
- `INVALID_OPERATION_TYPE` is the sixth protocol-level code in `CallError`
|
||||
(`retryable: false`, `details: None`)
|
||||
|
||||
2. **Registry conformance** (operation-registry.md §OperationRegistry):
|
||||
- `register()` validates `HandlerKind` matches `spec.op_type` (Once for
|
||||
Query/Mutation, Stream for Subscription) — mismatch is a startup error
|
||||
- `invoke()` dispatches `HandlerKind::Once` (existing path); returns
|
||||
`INVALID_OPERATION_TYPE` for `HandlerKind::Stream`
|
||||
- `invoke_streaming()` dispatches `HandlerKind::Stream`; returns
|
||||
`INVALID_OPERATION_TYPE` for `HandlerKind::Once`
|
||||
- `invoke_streaming()` pre-handler errors (not-found, forbidden,
|
||||
INVALID_OPERATION_TYPE) yield a single error `ResponseEnvelope` and end
|
||||
the stream
|
||||
- `invoke_streaming()` visibility + ACL checks identical to `invoke()`
|
||||
(same authority switch: internal → handler_identity, external → identity)
|
||||
- `OperationEnv` is request/response-only — no `invoke_streaming()` on the
|
||||
trait; `invoke()` on a `Subscription` returns `INVALID_OPERATION_TYPE`
|
||||
(via `LocalOperationEnv` → `registry.invoke()` and `OverlayOperationEnv`
|
||||
direct match)
|
||||
|
||||
3. **Builder conformance** (operation-registry.md §OperationRegistryBuilder):
|
||||
- `with_local` / `with_leaf` / `with_leaf_provenance` wrap `Handler` in
|
||||
`HandlerKind::Once` for Query/Mutation
|
||||
- `with_local_streaming` / `with_leaf_streaming` wrap `StreamingHandler` in
|
||||
`HandlerKind::Stream` for Subscription
|
||||
- Builder validates `handler` kind matches `spec.op_type`
|
||||
|
||||
4. **Call-protocol dispatch conformance** (call-protocol.md §CallAdapter Stream Handling):
|
||||
- `Dispatcher::handle_stream` branches on `op_type`: `Subscription` →
|
||||
`invoke_streaming()` → pump stream; `Query`/`Mutation` → `invoke()` (existing)
|
||||
- Streaming pump: each `ResponseEnvelope` → `EventEnvelope` frame
|
||||
- Natural stream end → `call.completed` frame
|
||||
- `Err` envelope → `call.error` frame, stream ends after it (NO
|
||||
`call.completed` after an error)
|
||||
- `deadline: None` for subscriptions (unbounded)
|
||||
- Abort: `call.aborted` drops the stream (Drop releases resources; ADR-016)
|
||||
|
||||
5. **from_call streaming forwarding** (client-and-adapters.md §from_call):
|
||||
- `build_bundles` branches on `op_type`: `Subscription` →
|
||||
`make_streaming_forwarding_handler` (`HandlerKind::Stream`),
|
||||
`Query`/`Mutation` → `make_forwarding_handler` (`HandlerKind::Once`)
|
||||
- Streaming forwarding handler calls `CallConnection::subscribe_with_payload()`
|
||||
(or `subscribe()` with forwarded payload)
|
||||
- `forwarded_for` populated from `context.identity` (ADR-032)
|
||||
- Remote stream forwarded end-to-end (no truncation, no first-value fallback)
|
||||
- `composition_authority: None`, `scoped_env: None` for FromCall streaming leaves
|
||||
|
||||
6. **Gateway dispatch conformance** (http-server.md §Streaming projection):
|
||||
- `GatewayDispatch::invoke_streaming()` exists, returns
|
||||
`BoxStream<ResponseEnvelope>`
|
||||
- Security invariants identical to `invoke()` (shared `build_root_context`):
|
||||
`internal: false`, `forwarded_for: None`, same capabilities, same scoped_env,
|
||||
same ACL
|
||||
- `deadline: None` for the streaming path
|
||||
- `to_mcp` does NOT call `invoke_streaming()` (MCP excludes Subscription)
|
||||
|
||||
7. **/subscribe SSE conformance** (http-server.md §Streaming projection):
|
||||
- `subscribe_handler` calls `GatewayDispatch::invoke_streaming()` (not `invoke()`)
|
||||
- Each `Ok(value)` → SSE `data:` frame
|
||||
- `Err` → SSE error event (`event: error`), stream ends after (terminal)
|
||||
- Natural stream end → SSE stream closes
|
||||
- Internal op → single `NOT_FOUND` error event (no leak)
|
||||
- Client disconnect → stream dropped (Drop; abort cascade)
|
||||
- Placeholder helpers removed (`subscribe_stream_from_envelope`,
|
||||
`envelope_to_sse_stream`)
|
||||
|
||||
8. **from_openapi SSE conformance** (http-adapters.md §Forwarding handler):
|
||||
- `build_registration` branches on `op_type`: `Subscription` →
|
||||
`HandlerKind::Stream` (streaming forward), `Query`/`Mutation` →
|
||||
`HandlerKind::Once` (existing)
|
||||
- `forward_stream()` streams SSE chunks as `ResponseEnvelope::ok()` items
|
||||
- HTTP error → single `ResponseEnvelope::error()`, stream ends
|
||||
- SSE stream end → `ResponseStream` ends
|
||||
- `parse_sse_frames` reused (multi-event, partial, comments, BOM)
|
||||
- `stream_subscription()` collect-all placeholder removed
|
||||
- `from_mcp` unchanged (always `HandlerKind::Once`)
|
||||
|
||||
9. **ADR conformance**:
|
||||
- ADR-049: all 9 decisions implemented (StreamingHandler, HandlerKind,
|
||||
invoke_streaming, invoke errors on Subscription, OperationEnv
|
||||
request/response-only, server-side dispatch branch,
|
||||
GatewayDispatch::invoke_streaming, from_call stream forwarding,
|
||||
from_openapi SSE forwarding)
|
||||
- ADR-023 amended: six protocol codes (INVALID_OPERATION_TYPE added)
|
||||
- ADR-016: abort cascade on streaming (stream drop)
|
||||
- ADR-032: forwarded_for on streaming forwarding handlers
|
||||
|
||||
10. **End-to-end correctness**:
|
||||
- A `Subscription` op registered with a `StreamingHandler` streams
|
||||
`call.responded` events through: server dispatch → wire → HTTP `/subscribe`
|
||||
SSE (or `from_call` forwarding → remote stream, or `from_openapi` SSE
|
||||
forwarding)
|
||||
- `invoke()` on a `Subscription` returns `INVALID_OPERATION_TYPE` (not silent
|
||||
truncation)
|
||||
- `invoke_streaming()` on a `Query`/`Mutation` returns
|
||||
`INVALID_OPERATION_TYPE`
|
||||
- `OperationEnv::invoke()` on a `Subscription` returns
|
||||
`INVALID_OPERATION_TYPE` (composition is request/response-only)
|
||||
|
||||
11. **Pattern consistency**:
|
||||
- `invoke()` and `invoke_streaming()` share visibility + ACL logic (security
|
||||
axis provably identical)
|
||||
- `GatewayDispatch::invoke()` and `invoke_streaming()` share
|
||||
`build_root_context` (security axis provably identical)
|
||||
- `HandlerKind` makes the "one or the other, matching op_type" invariant
|
||||
type-level (not two `Option`s validated at runtime)
|
||||
- Existing `Query`/`Mutation` handlers unchanged (wrapped in
|
||||
`HandlerKind::Once`, dispatch path identical)
|
||||
|
||||
12. **Test coverage**:
|
||||
- Unit tests for `HandlerKind` validation at `register()` (both mismatch
|
||||
directions)
|
||||
- Unit tests for `invoke()` / `invoke_streaming()` cross-kind errors
|
||||
(`INVALID_OPERATION_TYPE` both directions)
|
||||
- Unit tests for `invoke_streaming()` pre-handler errors (not-found,
|
||||
forbidden, internal-from-external)
|
||||
- Unit tests for `invoke_streaming()` ACL authority switch (internal →
|
||||
handler_identity)
|
||||
- Unit test for `make_streaming_handler`
|
||||
- Unit/integration tests for server-side streaming dispatch (multiple
|
||||
`call.responded` + `call.completed`; `Err` → `call.error`, no
|
||||
`call.completed` after)
|
||||
- Unit/integration tests for `from_call` streaming forwarding
|
||||
- Unit/integration tests for `from_openapi` SSE streaming forwarding
|
||||
- Unit tests for `/subscribe` SSE (multiple `data:` frames; `event:error`;
|
||||
`INVALID_OPERATION_TYPE` for `Query` op via `/subscribe`)
|
||||
- Unit tests for `GatewayDispatch::invoke_streaming()` (all error paths)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All type surface matches operation-registry.md §Handler
|
||||
- [ ] All registry methods match operation-registry.md §OperationRegistry
|
||||
- [ ] Builder wraps HandlerKind correctly per op_type
|
||||
- [ ] Call-protocol dispatch branches on op_type correctly
|
||||
- [ ] from_call streaming forwarding works end-to-end
|
||||
- [ ] GatewayDispatch::invoke_streaming security invariants identical to invoke
|
||||
- [ ] /subscribe SSE pipes BoxStream correctly
|
||||
- [ ] from_openapi SSE streaming works (no truncation)
|
||||
- [ ] from_mcp unchanged (always HandlerKind::Once)
|
||||
- [ ] ADR-049 all 9 decisions implemented
|
||||
- [ ] ADR-023 amended (six protocol codes)
|
||||
- [ ] invoke() / invoke_streaming() / OperationEnv::invoke() cross-kind errors
|
||||
all return INVALID_OPERATION_TYPE
|
||||
- [ ] Existing Query/Mutation handlers unchanged
|
||||
- [ ] Test coverage adequate for all streaming functionality
|
||||
- [ ] `cargo fmt --check -p alknet-call -p alknet-http` passes
|
||||
- [ ] `cargo clippy -p alknet-call -p alknet-http --all-targets` passes with no warnings
|
||||
- [ ] All tests pass (`cargo test -p alknet-call -p alknet-http`)
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049
|
||||
- docs/architecture/crates/call/operation-registry.md — Handler, OperationRegistry, HandlerRegistration
|
||||
- docs/architecture/crates/call/call-protocol.md — CallAdapter Stream Handling, call.error Payload
|
||||
- docs/architecture/crates/call/client-and-adapters.md — from_call streaming forwarding
|
||||
- docs/architecture/crates/http/http-server.md — Streaming projection (SSE)
|
||||
- docs/architecture/crates/http/http-adapters.md — Forwarding handler (from_openapi)
|
||||
- docs/architecture/crates/http/http-mcp.md — from_mcp always HandlerKind::Once
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (amended: six codes)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop)
|
||||
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 (forwarded_for)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the quality checkpoint for the ADR-049 streaming handler work — the
|
||||
> most significant cross-cutting change since the initial call/http
|
||||
> implementation. The review should verify the end-to-end streaming path works:
|
||||
> a `Subscription` op registered with a `StreamingHandler` streams
|
||||
> `call.responded` events through every projection (server dispatch → wire,
|
||||
> HTTP `/subscribe` SSE, `from_call` forwarding, `from_openapi` SSE forwarding).
|
||||
> The load-bearing invariants: (1) `invoke()` / `invoke_streaming()` /
|
||||
> `OperationEnv::invoke()` cross-kind errors all return
|
||||
> `INVALID_OPERATION_TYPE` (no silent truncation), (2) the security axis is
|
||||
> provably identical between `invoke()` and `invoke_streaming()` (shared
|
||||
> `build_root_context` + shared visibility/ACL logic), (3) `HandlerKind` makes
|
||||
> the kind/op_type invariant type-level, (4) existing `Query`/`Mutation`
|
||||
> handlers are unchanged. If deviations are found, document and fix before
|
||||
> considering the streaming handler work complete.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user