Compare commits
15 Commits
b3ab6ef097
...
feat/call/
| Author | SHA1 | Date | |
|---|---|---|---|
| c58eccd5a6 | |||
| 62bebe5122 | |||
| a1e4752fdf | |||
| 6f05dd8995 | |||
| d841cc35b9 | |||
| 5c37e5b3af | |||
| 67b1adba98 | |||
| f12e227df0 | |||
| acaa0513e4 | |||
| 185ddb82b5 | |||
| 9c81129f24 | |||
| 07f7607fbb | |||
| 7ecc11610a | |||
| 139c651eaa | |||
| 5a51734291 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
target/
|
target/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.worktrees/
|
||||||
@@ -572,7 +572,7 @@ mod tests {
|
|||||||
use crate::protocol::connection::CallConnection;
|
use crate::protocol::connection::CallConnection;
|
||||||
use crate::protocol::wire::ResponseEnvelope;
|
use crate::protocol::wire::ResponseEnvelope;
|
||||||
use crate::registry::registration::{
|
use crate::registry::registration::{
|
||||||
make_handler, Handler, HandlerRegistration, OperationProvenance,
|
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||||
};
|
};
|
||||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::Identity;
|
use alknet_core::auth::Identity;
|
||||||
@@ -640,14 +640,16 @@ mod tests {
|
|||||||
|
|
||||||
fn registry_with_caps() -> Arc<OperationRegistry> {
|
fn registry_with_caps() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("pub/run"),
|
.register(HandlerRegistration::new(
|
||||||
caps_inspect_handler(),
|
external_spec("pub/run"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(caps_inspect_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new().with_api_key("google", "pub-key".to_string()),
|
None,
|
||||||
));
|
Capabilities::new().with_api_key("google", "pub-key".to_string()),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,7 +711,9 @@ mod tests {
|
|||||||
let client = CallClient::new(Arc::clone(®istry), Arc::new(NoopIdentityProvider));
|
let client = CallClient::new(Arc::clone(®istry), Arc::new(NoopIdentityProvider));
|
||||||
let conn = client.spawn_dispatch(stub_connection());
|
let conn = client.spawn_dispatch(stub_connection());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
conn.connection().expect("quic connection present").remote_alpn(),
|
conn.connection()
|
||||||
|
.expect("quic connection present")
|
||||||
|
.remote_alpn(),
|
||||||
b"alknet/call"
|
b"alknet/call"
|
||||||
);
|
);
|
||||||
std::mem::drop(conn);
|
std::mem::drop(conn);
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ use crate::client::AdapterError;
|
|||||||
use crate::protocol::connection::CallConnection;
|
use crate::protocol::connection::CallConnection;
|
||||||
use crate::protocol::wire::ResponseEnvelope;
|
use crate::protocol::wire::ResponseEnvelope;
|
||||||
use crate::registry::context::OperationContext;
|
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::{
|
use crate::registry::spec::{
|
||||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||||
};
|
};
|
||||||
@@ -121,14 +123,23 @@ fn build_bundles(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let handler = make_forwarding_handler(
|
let kind = match spec.op_type {
|
||||||
Arc::new(op_summary.connection.clone()),
|
OperationType::Subscription => HandlerKind::Stream(make_streaming_forwarding_handler(
|
||||||
remote_name,
|
Arc::new(op_summary.connection.clone()),
|
||||||
op_summary.credentials_auth_token.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(
|
bundles.push(HandlerRegistration::new(
|
||||||
spec,
|
spec,
|
||||||
handler,
|
kind,
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -309,8 +320,10 @@ fn parse_access_control(v: &Value) -> AccessControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct a forwarding handler for a `FromCall` leaf: on invocation, calls
|
/// Construct a forwarding handler for a `FromCall` `Query`/`Mutation` leaf:
|
||||||
/// the remote op via the `CallConnection` and returns its `ResponseEnvelope`.
|
/// 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
|
/// Per ADR-032 §3, the handler populates `forwarded_for` on the
|
||||||
/// `call.requested` payload from the hub's `OperationContext.identity` (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
|
/// If `context.identity` is `None` (the hub chose not to disclose, or has not
|
||||||
/// authenticated an originator), `forwarded_for` is omitted — the spoke
|
/// authenticated an originator), `forwarded_for` is omitted — the spoke
|
||||||
/// receives only the hub's identity.
|
/// 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(
|
fn make_forwarding_handler(
|
||||||
connection: Arc<CallConnection>,
|
connection: Arc<CallConnection>,
|
||||||
remote_name: String,
|
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
|
/// Build the `call.requested` payload for a forwarded call, populating
|
||||||
/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3).
|
/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3).
|
||||||
/// `forwarded_for` is omitted when `context.identity` is `None` (the hub
|
/// `forwarded_for` is omitted when `context.identity` is `None` (the hub
|
||||||
@@ -389,7 +430,7 @@ fn build_forwarded_payload(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::protocol::connection::CallConnection;
|
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 crate::registry::spec::OperationType;
|
||||||
use alknet_core::auth::Identity;
|
use alknet_core::auth::Identity;
|
||||||
use alknet_core::types::{Capabilities, MockConnection};
|
use alknet_core::types::{Capabilities, MockConnection};
|
||||||
@@ -549,7 +590,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let reg = HandlerRegistration::new(
|
let reg = HandlerRegistration::new(
|
||||||
spec,
|
spec,
|
||||||
handler,
|
HandlerKind::Once(handler),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
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]
|
#[test]
|
||||||
fn build_bundles_same_peer_collision_returns_same_peer_collision_error() {
|
fn build_bundles_same_peer_collision_returns_same_peer_collision_error() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
@@ -822,4 +872,234 @@ mod tests {
|
|||||||
assert_eq!(bundles.len(), 1);
|
assert_eq!(bundles.len(), 1);
|
||||||
assert_eq!(bundles[0].spec.name, "worker/exec");
|
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::client::{AdapterError, OperationAdapter};
|
||||||
use crate::protocol::wire::{CallError, ResponseEnvelope};
|
use crate::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
use crate::registry::context::OperationContext;
|
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;
|
use crate::registry::spec::OperationSpec;
|
||||||
|
|
||||||
/// Build a [`HandlerRegistration`] from a JSON Schema-described operation.
|
/// Build a [`HandlerRegistration`] from a JSON Schema-described operation.
|
||||||
@@ -30,7 +32,7 @@ pub fn from_jsonschema(spec: OperationSpec, _schema: Value) -> HandlerRegistrati
|
|||||||
});
|
});
|
||||||
HandlerRegistration::new(
|
HandlerRegistration::new(
|
||||||
spec,
|
spec,
|
||||||
handler,
|
HandlerKind::Once(handler),
|
||||||
OperationProvenance::FromJsonSchema,
|
OperationProvenance::FromJsonSchema,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -138,7 +140,10 @@ mod tests {
|
|||||||
async fn placeholder_handler_returns_error_when_invoked() {
|
async fn placeholder_handler_returns_error_when_invoked() {
|
||||||
let bundle = from_jsonschema_fn::from_jsonschema(test_spec("ns/op"), serde_json::json!({}));
|
let bundle = from_jsonschema_fn::from_jsonschema(test_spec("ns/op"), serde_json::json!({}));
|
||||||
let ctx = test_context("req-1");
|
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 {
|
match response.result {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
assert_eq!(e.code, "NOT_FOUND");
|
assert_eq!(e.code, "NOT_FOUND");
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||||
use crate::registry::env::OperationEnv;
|
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 crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::AuthToken;
|
use alknet_core::auth::AuthToken;
|
||||||
use alknet_core::types::Capabilities;
|
use alknet_core::types::Capabilities;
|
||||||
@@ -245,22 +247,24 @@ mod tests {
|
|||||||
handler: crate::registry::registration::Handler,
|
handler: crate::registry::registration::Handler,
|
||||||
) -> Arc<OperationRegistry> {
|
) -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
OperationSpec::new(
|
.register(HandlerRegistration::new(
|
||||||
name,
|
OperationSpec::new(
|
||||||
OperationType::Query,
|
name,
|
||||||
visibility,
|
OperationType::Query,
|
||||||
serde_json::json!({}),
|
visibility,
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
vec![],
|
serde_json::json!({}),
|
||||||
acl,
|
vec![],
|
||||||
),
|
acl,
|
||||||
handler,
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,14 +425,16 @@ mod tests {
|
|||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
|
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
|
||||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("agent/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec("agent/run", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
Some(scoped.clone()),
|
None,
|
||||||
caps.clone(),
|
Some(scoped.clone()),
|
||||||
));
|
caps.clone(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let adapter = CallAdapter::new(registry, provider);
|
let adapter = CallAdapter::new(registry, provider);
|
||||||
@@ -543,7 +549,7 @@ mod tests {
|
|||||||
vec![],
|
vec![],
|
||||||
AccessControl::default(),
|
AccessControl::default(),
|
||||||
),
|
),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -610,7 +616,7 @@ mod tests {
|
|||||||
vec![],
|
vec![],
|
||||||
AccessControl::default(),
|
AccessControl::default(),
|
||||||
),
|
),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use super::wire::{
|
|||||||
use crate::protocol::wire::ResponseEnvelope;
|
use crate::protocol::wire::ResponseEnvelope;
|
||||||
use crate::registry::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv};
|
use crate::registry::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||||
use crate::registry::env::OperationEnv;
|
use crate::registry::env::OperationEnv;
|
||||||
use crate::registry::registration::{Handler, HandlerRegistration};
|
use crate::registry::registration::{HandlerKind, HandlerRegistration};
|
||||||
use crate::registry::spec::AccessResult;
|
use crate::registry::spec::AccessResult;
|
||||||
|
|
||||||
const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
|
const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
@@ -168,11 +168,26 @@ impl CallConnection {
|
|||||||
operation_id: &str,
|
operation_id: &str,
|
||||||
input: Value,
|
input: Value,
|
||||||
) -> impl Stream<Item = ResponseEnvelope> {
|
) -> impl Stream<Item = ResponseEnvelope> {
|
||||||
let request_id = generate_request_id();
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"operationId": operation_id,
|
"operationId": operation_id,
|
||||||
"input": input,
|
"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 {
|
let connection = match &self.connection {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -307,7 +322,7 @@ impl OperationEnv for OverlayOperationEnv {
|
|||||||
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let handler: Handler;
|
let handler: HandlerKind;
|
||||||
let composition_authority;
|
let composition_authority;
|
||||||
let scoped_env;
|
let scoped_env;
|
||||||
let access_control;
|
let access_control;
|
||||||
@@ -316,7 +331,7 @@ impl OperationEnv for OverlayOperationEnv {
|
|||||||
let Some(registration) = overlay.get(&name) else {
|
let Some(registration) = overlay.get(&name) else {
|
||||||
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
||||||
};
|
};
|
||||||
handler = Arc::clone(®istration.handler);
|
handler = registration.handler.clone();
|
||||||
composition_authority = registration.composition_authority.clone();
|
composition_authority = registration.composition_authority.clone();
|
||||||
scoped_env = registration
|
scoped_env = registration
|
||||||
.scoped_env
|
.scoped_env
|
||||||
@@ -355,7 +370,15 @@ impl OperationEnv for OverlayOperationEnv {
|
|||||||
internal: true,
|
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 {
|
fn contains(&self, name: &str) -> bool {
|
||||||
@@ -421,7 +444,7 @@ impl Stream for SubscriptionStream {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::registry::context::CompositionAuthority;
|
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 crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::types::{Capabilities, MockConnection};
|
use alknet_core::types::{Capabilities, MockConnection};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -476,7 +499,7 @@ mod tests {
|
|||||||
fn imported_registration(name: &str) -> HandlerRegistration {
|
fn imported_registration(name: &str) -> HandlerRegistration {
|
||||||
HandlerRegistration::new(
|
HandlerRegistration::new(
|
||||||
external_spec(name),
|
external_spec(name),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -608,7 +631,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
conn.register_imported(HandlerRegistration::new(
|
conn.register_imported(HandlerRegistration::new(
|
||||||
external_spec("worker/exec"),
|
external_spec("worker/exec"),
|
||||||
inspect_handler,
|
HandlerKind::Once(inspect_handler),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -631,7 +654,9 @@ mod tests {
|
|||||||
fn connection_accessor_returns_underlying_connection() {
|
fn connection_accessor_returns_underlying_connection() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
conn.connection().expect("quic connection present").remote_alpn(),
|
conn.connection()
|
||||||
|
.expect("quic connection present")
|
||||||
|
.remote_alpn(),
|
||||||
b"alknet/call"
|
b"alknet/call"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -960,4 +985,39 @@ mod tests {
|
|||||||
assert!(conn.connection().is_some(), "QUIC connection present");
|
assert!(conn.connection().is_some(), "QUIC connection present");
|
||||||
assert!(conn.identity().is_none(), "no identity set yet");
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||||
use alknet_core::types::StreamError;
|
use alknet_core::types::StreamError;
|
||||||
|
use futures::stream::StreamExt;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
@@ -30,11 +31,37 @@ use super::wire::{
|
|||||||
use crate::protocol::adapter::SessionOverlaySource;
|
use crate::protocol::adapter::SessionOverlaySource;
|
||||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||||
use crate::registry::env::{LocalOperationEnv, OperationEnv, PeerCompositeEnv};
|
use crate::registry::env::{LocalOperationEnv, OperationEnv, PeerCompositeEnv};
|
||||||
use crate::registry::registration::OperationRegistry;
|
use crate::registry::registration::{OperationRegistry, ResponseStream};
|
||||||
|
use crate::registry::spec::OperationType;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const SWEEPER_INTERVAL: Duration = Duration::from_secs(10);
|
const SWEEPER_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Outcome of dispatching a `call.requested` event. The dispatcher branches on
|
||||||
|
/// the registered operation's `op_type` (ADR-049 §6): `Query`/`Mutation` produce
|
||||||
|
/// a single [`ResponseEnvelope`] (`Once`), `Subscription` produces a
|
||||||
|
/// [`ResponseStream`] (`Stream`) that `handle_stream` pumps to the wire.
|
||||||
|
///
|
||||||
|
/// This enum is the branch point the spec describes ("branches on `op_type` in
|
||||||
|
/// `handle_stream`"): `dispatch` returns it and `handle_stream` matches on it,
|
||||||
|
/// keeping the Once path (one frame, no `call.completed`) and the Stream path
|
||||||
|
/// (each envelope → frame, `call.completed` on natural end) visibly distinct.
|
||||||
|
pub enum DispatchResult {
|
||||||
|
Once(ResponseEnvelope),
|
||||||
|
Stream(ResponseStream),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for DispatchResult {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DispatchResult::Once(env) => f.debug_tuple("Once").field(env).finish(),
|
||||||
|
DispatchResult::Stream(_) => {
|
||||||
|
f.debug_tuple("Stream").field(&"<ResponseStream>").finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared dispatcher for an established `CallConnection`. Constructed by
|
/// Shared dispatcher for an established `CallConnection`. Constructed by
|
||||||
/// both `CallAdapter` (accept path) and `CallClient` (connect path) and used
|
/// both `CallAdapter` (accept path) and `CallClient` (connect path) and used
|
||||||
/// to run the dispatch loop. Holds no per-connection state; the
|
/// to run the dispatch loop. Holds no per-connection state; the
|
||||||
@@ -166,6 +193,36 @@ impl Dispatcher {
|
|||||||
request_id: String,
|
request_id: String,
|
||||||
payload: Value,
|
payload: Value,
|
||||||
) -> ResponseEnvelope {
|
) -> ResponseEnvelope {
|
||||||
|
match self.dispatch(connection, request_id, payload).await {
|
||||||
|
DispatchResult::Once(envelope) => envelope,
|
||||||
|
DispatchResult::Stream(mut stream) => stream.next().await.unwrap_or_else(|| {
|
||||||
|
ResponseEnvelope::error(
|
||||||
|
String::new(),
|
||||||
|
CallError::internal(
|
||||||
|
"dispatch_requested called on a Subscription op; use the streaming path",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a `call.requested` event, branching on the registered
|
||||||
|
/// operation's `op_type` (ADR-049 §6). `Query`/`Mutation` → `invoke()` →
|
||||||
|
/// [`DispatchResult::Once`]; `Subscription` → `invoke_streaming()` →
|
||||||
|
/// [`DispatchResult::Stream`]. Unknown ops and ACL failures resolve via
|
||||||
|
/// the registry's own envelope/error paths (Once for `invoke`, a single
|
||||||
|
/// error envelope for `invoke_streaming`).
|
||||||
|
///
|
||||||
|
/// For the streaming branch the root context's deadline is cleared
|
||||||
|
/// (`deadline: None`): subscriptions are long-running and unbounded — the
|
||||||
|
/// 30s request/response deadline does not apply (ADR-049 §6, call-protocol
|
||||||
|
/// Timeouts). The Once branch keeps the deadline from `build_root_context`.
|
||||||
|
pub async fn dispatch(
|
||||||
|
&self,
|
||||||
|
connection: &Arc<CallConnection>,
|
||||||
|
request_id: String,
|
||||||
|
payload: Value,
|
||||||
|
) -> DispatchResult {
|
||||||
let operation_id = payload
|
let operation_id = payload
|
||||||
.get("operationId")
|
.get("operationId")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -180,7 +237,13 @@ impl Dispatcher {
|
|||||||
|
|
||||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||||
|
|
||||||
let context = self.build_root_context(
|
let is_subscription = self
|
||||||
|
.registry
|
||||||
|
.registration(&operation_name)
|
||||||
|
.map(|r| r.spec.op_type == OperationType::Subscription)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let mut context = self.build_root_context(
|
||||||
request_id.clone(),
|
request_id.clone(),
|
||||||
&operation_name,
|
&operation_name,
|
||||||
identity,
|
identity,
|
||||||
@@ -188,7 +251,16 @@ impl Dispatcher {
|
|||||||
connection,
|
connection,
|
||||||
);
|
);
|
||||||
|
|
||||||
self.registry.invoke(&operation_name, input, context).await
|
if is_subscription {
|
||||||
|
context.deadline = None;
|
||||||
|
let stream = self
|
||||||
|
.registry
|
||||||
|
.invoke_streaming(&operation_name, input, context);
|
||||||
|
DispatchResult::Stream(stream)
|
||||||
|
} else {
|
||||||
|
let envelope = self.registry.invoke(&operation_name, input, context).await;
|
||||||
|
DispatchResult::Once(envelope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_abort(&self, connection: &Arc<CallConnection>, request_id: &str) {
|
pub async fn handle_abort(&self, connection: &Arc<CallConnection>, request_id: &str) {
|
||||||
@@ -225,14 +297,20 @@ impl Dispatcher {
|
|||||||
let request_id = envelope.id.clone();
|
let request_id = envelope.id.clone();
|
||||||
let payload = envelope.payload.clone();
|
let payload = envelope.payload.clone();
|
||||||
|
|
||||||
let response = self
|
match self
|
||||||
.dispatch_requested(&connection, request_id.clone(), payload)
|
.dispatch(&connection, request_id.clone(), payload)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
let event: EventEnvelope = response.into();
|
DispatchResult::Once(response) => {
|
||||||
if let Err(err) = writer.write_frame(&event).await {
|
let event: EventEnvelope = response.into();
|
||||||
warn!(error = %err, "failed to write response frame; closing stream");
|
if let Err(err) = writer.write_frame(&event).await {
|
||||||
break;
|
warn!(error = %err, "failed to write response frame; closing stream");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchResult::Stream(stream) => {
|
||||||
|
self.pump_stream(&mut writer, &request_id, stream).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EVENT_ABORTED => {
|
EVENT_ABORTED => {
|
||||||
@@ -246,6 +324,43 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pump a subscription's [`ResponseStream`] to the wire: each
|
||||||
|
/// [`ResponseEnvelope`] becomes an [`EventEnvelope`] frame (`call.responded`
|
||||||
|
/// for `Ok`, `call.error` for `Err`). On natural stream end (the stream
|
||||||
|
/// returned `None` without the last item being an `Err`), write a
|
||||||
|
/// `call.completed` frame. An `Err` envelope is terminal — the stream
|
||||||
|
/// ends after it and we do NOT write `call.completed` (ADR-049 §6).
|
||||||
|
///
|
||||||
|
/// If a frame write fails the pump stops early; the stream is dropped on
|
||||||
|
/// return, releasing the handler's resources via `Drop` (ADR-016). The
|
||||||
|
/// pump is cancellable: it runs inside the `handle_stream` task, so a
|
||||||
|
/// `call.aborted` for this request ID (handled by `handle_abort` on
|
||||||
|
/// another stream) or connection close cancels the task and drops the
|
||||||
|
/// stream.
|
||||||
|
pub(crate) async fn pump_stream<W: tokio::io::AsyncWrite + Unpin>(
|
||||||
|
&self,
|
||||||
|
writer: &mut super::wire::FrameFramedWriter<W>,
|
||||||
|
request_id: &str,
|
||||||
|
mut stream: ResponseStream,
|
||||||
|
) {
|
||||||
|
let mut last_was_error = false;
|
||||||
|
while let Some(envelope) = stream.next().await {
|
||||||
|
last_was_error = envelope.result.is_err();
|
||||||
|
let event: EventEnvelope = envelope.into();
|
||||||
|
if let Err(err) = writer.write_frame(&event).await {
|
||||||
|
warn!(error = %err, "failed to write streaming frame; closing stream");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !last_was_error {
|
||||||
|
let completed = EventEnvelope::completed(request_id);
|
||||||
|
if let Err(err) = writer.write_frame(&completed).await {
|
||||||
|
warn!(error = %err, "failed to write call.completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the shared dispatch loop over an established `CallConnection`:
|
/// Run the shared dispatch loop over an established `CallConnection`:
|
||||||
/// spawn the pending-entry sweeper, accept bidirectional streams until the
|
/// spawn the pending-entry sweeper, accept bidirectional streams until the
|
||||||
/// connection closes, dispatch each stream via `handle_stream`, and fail
|
/// connection closes, dispatch each stream via `handle_stream`, and fail
|
||||||
@@ -325,8 +440,10 @@ impl Clone for Dispatcher {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::protocol::wire::EVENT_RESPONDED;
|
use crate::protocol::wire::{EVENT_COMPLETED, EVENT_ERROR, EVENT_RESPONDED};
|
||||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
use crate::registry::registration::{
|
||||||
|
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||||
|
};
|
||||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||||
use alknet_core::types::{Capabilities, MockConnection};
|
use alknet_core::types::{Capabilities, MockConnection};
|
||||||
@@ -412,24 +529,26 @@ mod tests {
|
|||||||
|
|
||||||
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
OperationSpec::new(
|
.register(HandlerRegistration::new(
|
||||||
name,
|
OperationSpec::new(
|
||||||
OperationType::Query,
|
name,
|
||||||
visibility,
|
OperationType::Query,
|
||||||
serde_json::json!({}),
|
visibility,
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
vec![],
|
serde_json::json!({}),
|
||||||
acl,
|
vec![],
|
||||||
),
|
acl,
|
||||||
make_handler(|input, context| async move {
|
),
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
}),
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
OperationProvenance::Local,
|
})),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,14 +570,16 @@ mod tests {
|
|||||||
serde_json::json!({ "has_google": has_google }),
|
serde_json::json!({ "has_google": has_google }),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("admin/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
handler,
|
external_spec("admin/run", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
caps,
|
None,
|
||||||
));
|
caps,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = Dispatcher::new(registry, provider);
|
let dp = Dispatcher::new(registry, provider);
|
||||||
@@ -486,20 +607,22 @@ mod tests {
|
|||||||
serde_json::json!({ "has_google": has_google }),
|
serde_json::json!({ "has_google": has_google }),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec(
|
.register(HandlerRegistration::new(
|
||||||
"admin/run",
|
external_spec(
|
||||||
AccessControl {
|
"admin/run",
|
||||||
required_scopes: vec!["admin".to_string()],
|
AccessControl {
|
||||||
..Default::default()
|
required_scopes: vec!["admin".to_string()],
|
||||||
},
|
..Default::default()
|
||||||
),
|
},
|
||||||
handler,
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
caps,
|
None,
|
||||||
));
|
caps,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
||||||
StaticIdentityProvider::new()
|
StaticIdentityProvider::new()
|
||||||
@@ -609,14 +732,16 @@ mod tests {
|
|||||||
serde_json::json!({ "forwarded_for_id": forwarded_id }),
|
serde_json::json!({ "forwarded_for_id": forwarded_id }),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("fs/readFile", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
handler,
|
external_spec("fs/readFile", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = Dispatcher::new(registry, provider);
|
let dp = Dispatcher::new(registry, provider);
|
||||||
@@ -648,14 +773,16 @@ mod tests {
|
|||||||
serde_json::json!({ "present": present }),
|
serde_json::json!({ "present": present }),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("fs/readFile", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
handler,
|
external_spec("fs/readFile", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = Dispatcher::new(registry, provider);
|
let dp = Dispatcher::new(registry, provider);
|
||||||
@@ -736,14 +863,16 @@ mod tests {
|
|||||||
serde_json::json!({ "peer_ids": peer_ids }),
|
serde_json::json!({ "peer_ids": peer_ids }),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("fs/readFile", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
handler,
|
external_spec("fs/readFile", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = Dispatcher::new(registry, provider);
|
let dp = Dispatcher::new(registry, provider);
|
||||||
@@ -795,7 +924,11 @@ mod tests {
|
|||||||
let child_id = "ws-abort-child".to_string();
|
let child_id = "ws-abort-child".to_string();
|
||||||
{
|
{
|
||||||
let mut pending = conn.pending().lock();
|
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(
|
pending.register_call(
|
||||||
child_id.clone(),
|
child_id.clone(),
|
||||||
Instant::now() + Duration::from_secs(30),
|
Instant::now() + Duration::from_secs(30),
|
||||||
@@ -844,11 +977,400 @@ mod tests {
|
|||||||
"input": { "v": 42 },
|
"input": { "v": 42 },
|
||||||
});
|
});
|
||||||
let request_id = "ws-roundtrip-1".to_string();
|
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());
|
assert!(response.result.is_ok());
|
||||||
let envelope: EventEnvelope = response.into();
|
let envelope: EventEnvelope = response.into();
|
||||||
assert_eq!(envelope.r#type, EVENT_RESPONDED);
|
assert_eq!(envelope.r#type, EVENT_RESPONDED);
|
||||||
assert_eq!(envelope.id, "ws-roundtrip-1");
|
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 }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- streaming dispatch branch (ADR-049 §6) ---------------------------
|
||||||
|
|
||||||
|
fn subscription_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
OperationType::Subscription,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
acl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_frame(envelope: &EventEnvelope) -> Vec<u8> {
|
||||||
|
let body = serde_json::to_vec(envelope).expect("serialize envelope");
|
||||||
|
let mut buf = (body.len() as u32).to_be_bytes().to_vec();
|
||||||
|
buf.extend_from_slice(&body);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_all_frames(
|
||||||
|
reader: &mut (impl tokio::io::AsyncRead + Unpin),
|
||||||
|
) -> Vec<EventEnvelope> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let _ = reader.read_to_end(&mut buf).await;
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
let mut cursor = std::io::Cursor::new(buf);
|
||||||
|
loop {
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
match tokio::io::AsyncReadExt::read_exact(&mut cursor, &mut len_buf).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
let mut body = vec![0u8; len];
|
||||||
|
if tokio::io::AsyncReadExt::read_exact(&mut cursor, &mut body)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let envelope: EventEnvelope =
|
||||||
|
serde_json::from_slice(&body).expect("deserialize written frame");
|
||||||
|
frames.push(envelope);
|
||||||
|
}
|
||||||
|
frames
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_with_subscription(
|
||||||
|
name: &str,
|
||||||
|
handler: crate::registry::registration::StreamingHandler,
|
||||||
|
) -> Arc<OperationRegistry> {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
subscription_spec(name, AccessControl::default()),
|
||||||
|
HandlerKind::Stream(handler),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_subscription_returns_stream_result() {
|
||||||
|
let handler = make_streaming_handler(|input, ctx| {
|
||||||
|
futures::stream::iter(vec![
|
||||||
|
ResponseEnvelope::ok(ctx.request_id.clone(), input.clone()),
|
||||||
|
ResponseEnvelope::ok(ctx.request_id.clone(), serde_json::json!({"done": true})),
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let registry = registry_with_subscription("events/stream", handler);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/events/stream",
|
||||||
|
"input": { "v": 1 },
|
||||||
|
});
|
||||||
|
match dp.dispatch(&conn, "sub-1".to_string(), payload).await {
|
||||||
|
DispatchResult::Stream(mut stream) => {
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
let first = stream.next().await.expect("first envelope");
|
||||||
|
assert_eq!(first.request_id, "sub-1");
|
||||||
|
assert_eq!(first.result, Ok(serde_json::json!({ "v": 1 })));
|
||||||
|
let second = stream.next().await.expect("second envelope");
|
||||||
|
assert_eq!(second.result, Ok(serde_json::json!({ "done": true })));
|
||||||
|
assert!(
|
||||||
|
stream.next().await.is_none(),
|
||||||
|
"stream ends after two values"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Stream, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_subscription_clears_deadline_to_none() {
|
||||||
|
let handler = make_streaming_handler(|_input, ctx| {
|
||||||
|
let deadline = ctx.deadline;
|
||||||
|
futures::stream::iter(vec![ResponseEnvelope::ok(
|
||||||
|
ctx.request_id.clone(),
|
||||||
|
serde_json::json!({ "deadline_is_none": deadline.is_none() }),
|
||||||
|
)])
|
||||||
|
});
|
||||||
|
let registry = registry_with_subscription("events/stream", handler);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/events/stream",
|
||||||
|
"input": {},
|
||||||
|
});
|
||||||
|
match dp.dispatch(&conn, "sub-dl".to_string(), payload).await {
|
||||||
|
DispatchResult::Stream(mut stream) => {
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
let env = stream.next().await.expect("one envelope");
|
||||||
|
let out = env.result.expect("ok");
|
||||||
|
assert_eq!(out["deadline_is_none"], Value::Bool(true));
|
||||||
|
}
|
||||||
|
other => panic!("expected Stream, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_query_keeps_deadline_some() {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
let handler = make_handler(|_input, ctx| async move {
|
||||||
|
let deadline_is_some = ctx.deadline.is_some();
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
ctx.request_id.clone(),
|
||||||
|
serde_json::json!({ "deadline_is_some": deadline_is_some }),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
external_spec("echo/run", 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);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/echo/run",
|
||||||
|
"input": {},
|
||||||
|
});
|
||||||
|
match dp.dispatch(&conn, "q-1".to_string(), payload).await {
|
||||||
|
DispatchResult::Once(env) => {
|
||||||
|
let out = env.result.expect("ok");
|
||||||
|
assert_eq!(out["deadline_is_some"], Value::Bool(true));
|
||||||
|
}
|
||||||
|
other => panic!("expected Once, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_stream_subscription_pumps_each_frame_then_completed() {
|
||||||
|
let handler = make_streaming_handler(|input, ctx| {
|
||||||
|
let first = input.clone();
|
||||||
|
let rid = ctx.request_id.clone();
|
||||||
|
futures::stream::iter(vec![
|
||||||
|
ResponseEnvelope::ok(rid.clone(), first),
|
||||||
|
ResponseEnvelope::ok(rid.clone(), serde_json::json!({"n": 2})),
|
||||||
|
ResponseEnvelope::ok(rid, serde_json::json!({"n": 3})),
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let registry = registry_with_subscription("events/stream", handler);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let request = EventEnvelope::requested(
|
||||||
|
"sub-pump-1",
|
||||||
|
serde_json::json!({
|
||||||
|
"operationId": "/events/stream",
|
||||||
|
"input": { "n": 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||||
|
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||||
|
let send = alknet_core::types::SendStream::from_mock(send);
|
||||||
|
let recv = alknet_core::types::RecvStream::from_mock(recv);
|
||||||
|
|
||||||
|
dp.handle_stream(conn, send, recv).await;
|
||||||
|
|
||||||
|
let frames = read_all_frames(&mut sink).await;
|
||||||
|
assert_eq!(frames.len(), 4, "3 responded + 1 completed");
|
||||||
|
for (i, f) in frames[..3].iter().enumerate() {
|
||||||
|
assert_eq!(f.r#type, EVENT_RESPONDED, "frame {i} is call.responded");
|
||||||
|
assert_eq!(f.id, "sub-pump-1");
|
||||||
|
}
|
||||||
|
assert_eq!(frames[3].r#type, EVENT_COMPLETED);
|
||||||
|
assert_eq!(frames[3].id, "sub-pump-1");
|
||||||
|
assert_eq!(frames[3].payload, serde_json::json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_stream_subscription_error_is_terminal_no_completed() {
|
||||||
|
let handler = make_streaming_handler(|_input, ctx| {
|
||||||
|
let rid = ctx.request_id.clone();
|
||||||
|
futures::stream::iter(vec![
|
||||||
|
ResponseEnvelope::ok(rid.clone(), serde_json::json!({"ok": true})),
|
||||||
|
ResponseEnvelope::error(rid.clone(), CallError::internal("boom")),
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let registry = registry_with_subscription("events/stream", handler);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let request = EventEnvelope::requested(
|
||||||
|
"sub-err-1",
|
||||||
|
serde_json::json!({
|
||||||
|
"operationId": "/events/stream",
|
||||||
|
"input": {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||||
|
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||||
|
let send = alknet_core::types::SendStream::from_mock(send);
|
||||||
|
let recv = alknet_core::types::RecvStream::from_mock(recv);
|
||||||
|
|
||||||
|
dp.handle_stream(conn, send, recv).await;
|
||||||
|
|
||||||
|
let frames = read_all_frames(&mut sink).await;
|
||||||
|
assert_eq!(frames.len(), 2, "1 responded + 1 error, no completed");
|
||||||
|
assert_eq!(frames[0].r#type, EVENT_RESPONDED);
|
||||||
|
assert_eq!(frames[1].r#type, EVENT_ERROR);
|
||||||
|
assert_eq!(frames[1].id, "sub-err-1");
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].payload.get("code"),
|
||||||
|
Some(&Value::String("INTERNAL".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_stream_query_dispatch_unchanged_one_frame_no_completed() {
|
||||||
|
let registry = Arc::new(registry_with(
|
||||||
|
"echo/run",
|
||||||
|
Visibility::External,
|
||||||
|
AccessControl::default(),
|
||||||
|
));
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let request = EventEnvelope::requested(
|
||||||
|
"q-pump-1",
|
||||||
|
serde_json::json!({
|
||||||
|
"operationId": "/echo/run",
|
||||||
|
"input": { "msg": "hi" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||||
|
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||||
|
let send = alknet_core::types::SendStream::from_mock(send);
|
||||||
|
let recv = alknet_core::types::RecvStream::from_mock(recv);
|
||||||
|
|
||||||
|
dp.handle_stream(conn, send, recv).await;
|
||||||
|
|
||||||
|
let frames = read_all_frames(&mut sink).await;
|
||||||
|
assert_eq!(frames.len(), 1, "query: exactly one frame, no completed");
|
||||||
|
assert_eq!(frames[0].r#type, EVENT_RESPONDED);
|
||||||
|
assert_eq!(frames[0].id, "q-pump-1");
|
||||||
|
assert_eq!(
|
||||||
|
frames[0].payload.get("output"),
|
||||||
|
Some(&serde_json::json!({ "msg": "hi" }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_stream_subscription_unknown_op_yields_single_error_no_completed() {
|
||||||
|
let registry = Arc::new(OperationRegistry::new());
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let request = EventEnvelope::requested(
|
||||||
|
"sub-missing-1",
|
||||||
|
serde_json::json!({
|
||||||
|
"operationId": "/no/such/stream",
|
||||||
|
"input": {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||||
|
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||||
|
let send = alknet_core::types::SendStream::from_mock(send);
|
||||||
|
let recv = alknet_core::types::RecvStream::from_mock(recv);
|
||||||
|
|
||||||
|
dp.handle_stream(conn, send, recv).await;
|
||||||
|
|
||||||
|
let frames = read_all_frames(&mut sink).await;
|
||||||
|
assert_eq!(frames.len(), 1, "unknown op: single error, no completed");
|
||||||
|
assert_eq!(frames[0].r#type, EVENT_ERROR);
|
||||||
|
assert_eq!(frames[0].id, "sub-missing-1");
|
||||||
|
assert_eq!(
|
||||||
|
frames[0].payload.get("code"),
|
||||||
|
Some(&Value::String("NOT_FOUND".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_stream_aborted_for_streaming_request_drops_stream() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc as StdArc;
|
||||||
|
|
||||||
|
let dropped = StdArc::new(AtomicBool::new(false));
|
||||||
|
let dropped_clone = StdArc::clone(&dropped);
|
||||||
|
let handler = make_streaming_handler(move |_input, ctx| {
|
||||||
|
let rid = ctx.request_id.clone();
|
||||||
|
let flag = StdArc::clone(&dropped_clone);
|
||||||
|
struct DropGuard(StdArc<AtomicBool>);
|
||||||
|
impl Drop for DropGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let guard = DropGuard(StdArc::clone(&flag));
|
||||||
|
futures::stream::poll_fn(move |_cx| {
|
||||||
|
if flag.load(Ordering::SeqCst) {
|
||||||
|
return std::task::Poll::Ready(None);
|
||||||
|
}
|
||||||
|
std::task::Poll::Ready(Some(ResponseEnvelope::ok(
|
||||||
|
rid.clone(),
|
||||||
|
serde_json::json!({"tick": 1}),
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.map(move |env| {
|
||||||
|
let _keep_guard = &guard;
|
||||||
|
env
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let registry = registry_with_subscription("events/stream", handler);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let request = EventEnvelope::requested(
|
||||||
|
"sub-abort-1",
|
||||||
|
serde_json::json!({
|
||||||
|
"operationId": "/events/stream",
|
||||||
|
"input": {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||||
|
let (send, _sink) = tokio::io::duplex(8 * 1024);
|
||||||
|
let send = alknet_core::types::SendStream::from_mock(send);
|
||||||
|
let recv = alknet_core::types::RecvStream::from_mock(recv);
|
||||||
|
|
||||||
|
let conn_clone = Arc::clone(&conn);
|
||||||
|
let dp_clone = dp.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
dp_clone.handle_stream(conn_clone, send, recv).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
dp.handle_abort(&conn, "sub-abort-1").await;
|
||||||
|
assert!(
|
||||||
|
!conn.pending().lock().contains("sub-abort-1"),
|
||||||
|
"abort removes the pending entry"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.abort();
|
||||||
|
let _ = handle.await;
|
||||||
|
assert!(
|
||||||
|
dropped.load(Ordering::SeqCst),
|
||||||
|
"stream future dropped → Drop guard released handler resources"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ impl CallError {
|
|||||||
pub fn timeout(message: impl Into<String>) -> Self {
|
pub fn timeout(message: impl Into<String>) -> Self {
|
||||||
Self::new("TIMEOUT", message, true)
|
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 {}
|
impl Eq for CallError {}
|
||||||
|
|||||||
@@ -324,7 +324,10 @@ pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::registry::context::{CompositionAuthority, ScopedPeerEnv};
|
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 alknet_core::types::Capabilities;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
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> {
|
fn noop_env() -> Arc<dyn crate::registry::env::OperationEnv + Send + Sync> {
|
||||||
struct NoopEnv;
|
struct NoopEnv;
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -439,36 +448,42 @@ mod tests {
|
|||||||
|
|
||||||
fn registry_with_access_controlled_ops() -> Arc<OperationRegistry> {
|
fn registry_with_access_controlled_ops() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec_with_acl("public/echo", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec_with_acl("public/echo", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
external_spec_with_acl(
|
.unwrap();
|
||||||
"admin/secret",
|
registry
|
||||||
AccessControl {
|
.register(HandlerRegistration::new(
|
||||||
required_scopes: vec!["admin".to_string()],
|
external_spec_with_acl(
|
||||||
..Default::default()
|
"admin/secret",
|
||||||
},
|
AccessControl {
|
||||||
),
|
required_scopes: vec!["admin".to_string()],
|
||||||
echo_handler(),
|
..Default::default()
|
||||||
OperationProvenance::Local,
|
},
|
||||||
None,
|
),
|
||||||
None,
|
HandlerKind::Once(echo_handler()),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
registry.register(HandlerRegistration::new(
|
None,
|
||||||
internal_spec("internal/hidden"),
|
Capabilities::new(),
|
||||||
echo_handler(),
|
))
|
||||||
OperationProvenance::Local,
|
.unwrap();
|
||||||
None,
|
registry
|
||||||
None,
|
.register(HandlerRegistration::new(
|
||||||
Capabilities::new(),
|
internal_spec("internal/hidden"),
|
||||||
));
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,59 +500,67 @@ mod tests {
|
|||||||
|
|
||||||
fn registry_with_ops() -> Arc<OperationRegistry> {
|
fn registry_with_ops() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("fs/readFile"),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec("fs/readFile"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
internal_spec("secret/internal"),
|
.unwrap();
|
||||||
echo_handler(),
|
registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
None,
|
internal_spec("secret/internal"),
|
||||||
None,
|
HandlerKind::Once(echo_handler()),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
registry.register(HandlerRegistration::new(
|
None,
|
||||||
OperationSpec::new(
|
Capabilities::new(),
|
||||||
"events/subscribe",
|
))
|
||||||
OperationType::Subscription,
|
.unwrap();
|
||||||
Visibility::External,
|
registry
|
||||||
json!({}),
|
.register(HandlerRegistration::new(
|
||||||
json!({}),
|
OperationSpec::new(
|
||||||
vec![],
|
"events/subscribe",
|
||||||
AccessControl::default(),
|
OperationType::Subscription,
|
||||||
),
|
Visibility::External,
|
||||||
echo_handler(),
|
json!({}),
|
||||||
OperationProvenance::Local,
|
json!({}),
|
||||||
None,
|
vec![],
|
||||||
None,
|
AccessControl::default(),
|
||||||
Capabilities::new(),
|
),
|
||||||
));
|
HandlerKind::Stream(echo_streaming_handler()),
|
||||||
registry.register(HandlerRegistration::new(
|
OperationProvenance::Local,
|
||||||
OperationSpec::new(
|
None,
|
||||||
"fs/readFileErr",
|
None,
|
||||||
OperationType::Query,
|
Capabilities::new(),
|
||||||
Visibility::External,
|
))
|
||||||
json!({}),
|
.unwrap();
|
||||||
json!({}),
|
registry
|
||||||
vec![super::super::spec::ErrorDefinition {
|
.register(HandlerRegistration::new(
|
||||||
code: "FILE_NOT_FOUND".to_string(),
|
OperationSpec::new(
|
||||||
description: "file not found".to_string(),
|
"fs/readFileErr",
|
||||||
schema: json!({ "type": "object" }),
|
OperationType::Query,
|
||||||
http_status: None,
|
Visibility::External,
|
||||||
}],
|
json!({}),
|
||||||
AccessControl::default(),
|
json!({}),
|
||||||
),
|
vec![super::super::spec::ErrorDefinition {
|
||||||
echo_handler(),
|
code: "FILE_NOT_FOUND".to_string(),
|
||||||
OperationProvenance::Local,
|
description: "file not found".to_string(),
|
||||||
None,
|
schema: json!({ "type": "object" }),
|
||||||
None,
|
http_status: None,
|
||||||
Capabilities::new(),
|
}],
|
||||||
));
|
AccessControl::default(),
|
||||||
|
),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,22 +692,26 @@ mod tests {
|
|||||||
let schema_handler = services_schema_handler(Arc::clone(®istry));
|
let schema_handler = services_schema_handler(Arc::clone(®istry));
|
||||||
|
|
||||||
let mut discovery_registry = OperationRegistry::new();
|
let mut discovery_registry = OperationRegistry::new();
|
||||||
discovery_registry.register(HandlerRegistration::new(
|
discovery_registry
|
||||||
services_list_spec(),
|
.register(HandlerRegistration::new(
|
||||||
list_handler,
|
services_list_spec(),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(list_handler),
|
||||||
CompositionAuthority::none(),
|
OperationProvenance::Local,
|
||||||
ScopedPeerEnv::empty().into(),
|
CompositionAuthority::none(),
|
||||||
Capabilities::new(),
|
ScopedPeerEnv::empty().into(),
|
||||||
));
|
Capabilities::new(),
|
||||||
discovery_registry.register(HandlerRegistration::new(
|
))
|
||||||
services_schema_spec(),
|
.unwrap();
|
||||||
schema_handler,
|
discovery_registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
CompositionAuthority::none(),
|
services_schema_spec(),
|
||||||
ScopedPeerEnv::empty().into(),
|
HandlerKind::Once(schema_handler),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
CompositionAuthority::none(),
|
||||||
|
ScopedPeerEnv::empty().into(),
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let discovery = Arc::new(discovery_registry);
|
let discovery = Arc::new(discovery_registry);
|
||||||
|
|
||||||
let ctx = root_context("req-6");
|
let ctx = root_context("req-6");
|
||||||
|
|||||||
@@ -303,7 +303,9 @@ impl OperationEnv for PeerCompositeEnv {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::registry::context::CompositionAuthority;
|
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 crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::Identity;
|
use alknet_core::auth::Identity;
|
||||||
use alknet_core::types::Capabilities;
|
use alknet_core::types::Capabilities;
|
||||||
@@ -406,22 +408,24 @@ mod tests {
|
|||||||
scoped_env: Option<ScopedPeerEnv>,
|
scoped_env: Option<ScopedPeerEnv>,
|
||||||
) -> Arc<OperationRegistry> {
|
) -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
OperationSpec::new(
|
.register(HandlerRegistration::new(
|
||||||
name,
|
OperationSpec::new(
|
||||||
OperationType::Query,
|
name,
|
||||||
spec_visibility,
|
OperationType::Query,
|
||||||
serde_json::json!({}),
|
spec_visibility,
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
vec![],
|
serde_json::json!({}),
|
||||||
AccessControl::default(),
|
vec![],
|
||||||
),
|
AccessControl::default(),
|
||||||
handler,
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(handler),
|
||||||
composition_authority,
|
OperationProvenance::Local,
|
||||||
scoped_env,
|
composition_authority,
|
||||||
Capabilities::new(),
|
scoped_env,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ use alknet_call::registry::discovery::{
|
|||||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||||
};
|
};
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::{Identity, IdentityProvider};
|
use alknet_core::auth::{Identity, IdentityProvider};
|
||||||
@@ -124,58 +124,66 @@ async fn build_raw_quinn_server(
|
|||||||
/// services/list + services/schema discovery handlers.
|
/// services/list + services/schema discovery handlers.
|
||||||
fn build_server_registry() -> Arc<OperationRegistry> {
|
fn build_server_registry() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("server/echo"),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec("server/echo"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
external_spec("server/secret"),
|
.unwrap();
|
||||||
echo_handler(),
|
registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
None,
|
external_spec("server/secret"),
|
||||||
None,
|
HandlerKind::Once(echo_handler()),
|
||||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let discovery_registry = Arc::new(registry);
|
let discovery_registry = Arc::new(registry);
|
||||||
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
|
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
|
||||||
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
|
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
|
||||||
let mut full = OperationRegistry::new();
|
let mut full = OperationRegistry::new();
|
||||||
full.register(HandlerRegistration::new(
|
full.register(HandlerRegistration::new(
|
||||||
external_spec("server/echo"),
|
external_spec("server/echo"),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
));
|
))
|
||||||
|
.unwrap();
|
||||||
full.register(HandlerRegistration::new(
|
full.register(HandlerRegistration::new(
|
||||||
external_spec("server/secret"),
|
external_spec("server/secret"),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||||
));
|
))
|
||||||
|
.unwrap();
|
||||||
full.register(HandlerRegistration::new(
|
full.register(HandlerRegistration::new(
|
||||||
services_list_spec(),
|
services_list_spec(),
|
||||||
list_handler,
|
HandlerKind::Once(list_handler),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
));
|
))
|
||||||
|
.unwrap();
|
||||||
full.register(HandlerRegistration::new(
|
full.register(HandlerRegistration::new(
|
||||||
services_schema_spec(),
|
services_schema_spec(),
|
||||||
schema_handler,
|
HandlerKind::Once(schema_handler),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
));
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(full)
|
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
|
// it as UnknownIssuer since the self-signed cert is not in the platform
|
||||||
// root store.
|
// root store.
|
||||||
let mut client_registry = OperationRegistry::new();
|
let mut client_registry = OperationRegistry::new();
|
||||||
client_registry.register(HandlerRegistration::new(
|
client_registry
|
||||||
external_spec("client/echo"),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec("client/echo"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let client_registry = Arc::new(client_registry);
|
let client_registry = Arc::new(client_registry);
|
||||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
use alknet_call::client::{AdapterError, OperationAdapter};
|
use alknet_call::client::{AdapterError, OperationAdapter};
|
||||||
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
use alknet_call::registry::context::OperationContext;
|
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::{
|
use alknet_call::registry::spec::{
|
||||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||||
};
|
};
|
||||||
@@ -156,7 +158,7 @@ fn build_registration(
|
|||||||
|
|
||||||
HandlerRegistration::new(
|
HandlerRegistration::new(
|
||||||
spec,
|
spec,
|
||||||
handler,
|
HandlerKind::Once(handler),
|
||||||
OperationProvenance::FromMCP,
|
OperationProvenance::FromMCP,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ use std::sync::Arc;
|
|||||||
use alknet_call::client::{AdapterError, OperationAdapter};
|
use alknet_call::client::{AdapterError, OperationAdapter};
|
||||||
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
use alknet_call::registry::context::OperationContext;
|
use alknet_call::registry::context::OperationContext;
|
||||||
use alknet_call::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
use alknet_call::registry::registration::{
|
||||||
|
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||||
|
ResponseStream,
|
||||||
|
};
|
||||||
use alknet_call::registry::spec::{
|
use alknet_call::registry::spec::{
|
||||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||||
};
|
};
|
||||||
use alknet_core::types::Capabilities;
|
use alknet_core::types::Capabilities;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use futures::stream;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
@@ -438,33 +442,61 @@ impl FromOpenAPI {
|
|||||||
.map(|e| (e.http_status.unwrap_or(0), e.code.clone()))
|
.map(|e| (e.http_status.unwrap_or(0), e.code.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let handler = make_handler(move |input: Value, context: OperationContext| {
|
let handler = if op_type == OperationType::Subscription {
|
||||||
let path_template = path_template.clone();
|
let stream_handler =
|
||||||
let method_upper = method_upper.clone();
|
make_streaming_handler(move |input: Value, context: OperationContext| {
|
||||||
let auth_scheme = auth_scheme.clone();
|
let path_template = path_template.clone();
|
||||||
let default_headers = default_headers.clone();
|
let method_upper = method_upper.clone();
|
||||||
let base_url = base_url.clone();
|
let auth_scheme = auth_scheme.clone();
|
||||||
let namespace = namespace.clone();
|
let default_headers = default_headers.clone();
|
||||||
let http_client = Arc::clone(&http_client);
|
let base_url = base_url.clone();
|
||||||
let error_status_codes = error_status_codes.clone();
|
let namespace = namespace.clone();
|
||||||
let op_type = op_type;
|
let http_client = Arc::clone(&http_client);
|
||||||
async move {
|
let error_status_codes = error_status_codes.clone();
|
||||||
forward(
|
forward_stream(
|
||||||
&http_client,
|
&http_client,
|
||||||
&base_url,
|
&base_url,
|
||||||
&path_template,
|
&path_template,
|
||||||
&method_upper,
|
&method_upper,
|
||||||
&auth_scheme,
|
&auth_scheme,
|
||||||
&default_headers,
|
&default_headers,
|
||||||
&namespace,
|
&namespace,
|
||||||
&error_status_codes,
|
&error_status_codes,
|
||||||
op_type,
|
input,
|
||||||
input,
|
context,
|
||||||
context,
|
)
|
||||||
)
|
});
|
||||||
.await
|
HandlerKind::Stream(stream_handler)
|
||||||
}
|
} else {
|
||||||
});
|
let once_handler = make_handler(move |input: Value, context: OperationContext| {
|
||||||
|
let path_template = path_template.clone();
|
||||||
|
let method_upper = method_upper.clone();
|
||||||
|
let auth_scheme = auth_scheme.clone();
|
||||||
|
let default_headers = default_headers.clone();
|
||||||
|
let base_url = base_url.clone();
|
||||||
|
let namespace = namespace.clone();
|
||||||
|
let http_client = Arc::clone(&http_client);
|
||||||
|
let error_status_codes = error_status_codes.clone();
|
||||||
|
let op_type = op_type;
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
let capabilities = Capabilities::new();
|
let capabilities = Capabilities::new();
|
||||||
Ok(HandlerRegistration::new(
|
Ok(HandlerRegistration::new(
|
||||||
@@ -664,10 +696,6 @@ async fn forward(
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
if op_type == OperationType::Subscription && status.is_success() {
|
|
||||||
return stream_subscription(request_id, response).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let code = error_status_codes
|
let code = error_status_codes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -719,35 +747,136 @@ async fn forward(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_subscription(request_id: String, response: reqwest::Response) -> ResponseEnvelope {
|
#[allow(clippy::too_many_arguments)]
|
||||||
let mut stream = response.bytes_stream();
|
fn forward_stream(
|
||||||
let mut buffer = String::new();
|
http_client: &Arc<SharedHttpClient>,
|
||||||
let mut last_event: Option<Value> = None;
|
base_url: &str,
|
||||||
while let Some(chunk_result) = stream.next().await {
|
path_template: &str,
|
||||||
match chunk_result {
|
method: &str,
|
||||||
Ok(chunk) => {
|
auth_scheme: &Option<HttpAuthScheme>,
|
||||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
default_headers: &HashMap<String, String>,
|
||||||
let (events, remaining) = parse_sse_frames(&buffer);
|
namespace: &str,
|
||||||
buffer = remaining;
|
error_status_codes: &[(u16, String)],
|
||||||
for event in events {
|
input: Value,
|
||||||
let parsed = if event.data.trim().is_empty() {
|
context: OperationContext,
|
||||||
Value::Null
|
) -> ResponseStream {
|
||||||
} else {
|
let request_id = context.request_id.clone();
|
||||||
serde_json::from_str(&event.data)
|
|
||||||
.unwrap_or(Value::String(event.data.clone()))
|
let (http_method, url, body, headers) = match build_request(
|
||||||
};
|
base_url,
|
||||||
last_event = Some(parsed.clone());
|
path_template,
|
||||||
|
method,
|
||||||
|
auth_scheme,
|
||||||
|
default_headers,
|
||||||
|
namespace,
|
||||||
|
&input,
|
||||||
|
&context,
|
||||||
|
) {
|
||||||
|
Ok(parts) => parts,
|
||||||
|
Err(err) => {
|
||||||
|
return Box::pin(stream::once(async move {
|
||||||
|
ResponseEnvelope::error(request_id, err)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let http_client = Arc::clone(http_client);
|
||||||
|
let error_status_codes = error_status_codes.to_vec();
|
||||||
|
|
||||||
|
let request_id_stream = request_id.clone();
|
||||||
|
let error_status_codes_stream = error_status_codes.clone();
|
||||||
|
|
||||||
|
let init = async move {
|
||||||
|
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) => {
|
||||||
|
let serialized = serde_json::to_string(b).unwrap_or_else(|_| String::from("null"));
|
||||||
|
request_builder.body(serialized)
|
||||||
|
}
|
||||||
|
None => request_builder,
|
||||||
|
};
|
||||||
|
request_builder.send().await
|
||||||
|
};
|
||||||
|
|
||||||
|
let sse = stream::once(init).flat_map(move |result| {
|
||||||
|
let request_id = request_id_stream.clone();
|
||||||
|
let error_status_codes = error_status_codes_stream.clone();
|
||||||
|
match result {
|
||||||
|
Err(err) => Box::pin(stream::once(async move {
|
||||||
|
ResponseEnvelope::error(
|
||||||
|
request_id,
|
||||||
|
CallError::internal(format!("HTTP request failed: {err}")),
|
||||||
|
)
|
||||||
|
})) as ResponseStream,
|
||||||
|
Ok(response) => {
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
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("")
|
||||||
|
);
|
||||||
|
Box::pin(stream::once(async move {
|
||||||
|
ResponseEnvelope::error(request_id, CallError::new(code, message, false))
|
||||||
|
})) as ResponseStream
|
||||||
|
} else {
|
||||||
|
let request_id_inner = request_id.clone();
|
||||||
|
Box::pin(
|
||||||
|
stream::unfold(
|
||||||
|
(response.bytes_stream(), String::new()),
|
||||||
|
move |(mut bytes, mut buffer)| {
|
||||||
|
let request_id = request_id_inner.clone();
|
||||||
|
async move {
|
||||||
|
match bytes.next().await {
|
||||||
|
Some(Ok(chunk)) => {
|
||||||
|
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||||
|
let (events, remaining) = parse_sse_frames(&buffer);
|
||||||
|
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, (bytes, remaining)))
|
||||||
|
}
|
||||||
|
Some(Err(err)) => {
|
||||||
|
let error = CallError::internal(format!(
|
||||||
|
"SSE stream error: {err}"
|
||||||
|
));
|
||||||
|
Some((
|
||||||
|
vec![ResponseEnvelope::error(request_id, error)],
|
||||||
|
(bytes, buffer),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.flat_map(stream::iter),
|
||||||
|
) as ResponseStream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
|
||||||
return ResponseEnvelope::error(
|
|
||||||
request_id,
|
|
||||||
CallError::internal(format!("SSE stream error: {err}")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
ResponseEnvelope::ok(request_id, last_event.unwrap_or(Value::Null))
|
|
||||||
|
Box::pin(sse)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SseEvent {
|
struct SseEvent {
|
||||||
@@ -1151,7 +1280,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let ctx = noop_context("req-10", Capabilities::new());
|
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");
|
assert_eq!(response.request_id, "req-10");
|
||||||
match response.result {
|
match response.result {
|
||||||
Ok(v) => assert_eq!(v, serde_json::json!({"ok":true})),
|
Ok(v) => assert_eq!(v, serde_json::json!({"ok":true})),
|
||||||
@@ -1176,7 +1308,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let ctx = noop_context("req-11", Capabilities::new());
|
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 {
|
match response.result {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
assert_eq!(e.code, "HTTP_404");
|
assert_eq!(e.code, "HTTP_404");
|
||||||
@@ -1186,6 +1321,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subscription_op_registration_is_handler_kind_stream() {
|
||||||
|
let spec = OpenAPISpec::from_json(
|
||||||
|
r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/stream":{"post":{"operationId":"stream","responses":{"200":{"content":{"text/event-stream":{"schema":{}}}}}}}}}"##,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bundles = adapter(spec, config("svc", "https://x", None))
|
||||||
|
.import()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(bundles[0].handler, HandlerKind::Stream(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn query_op_registration_is_handler_kind_once() {
|
||||||
|
let spec = OpenAPISpec::from_json(
|
||||||
|
r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/data":{"get":{"operationId":"data","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bundles = adapter(spec, config("svc", "https://x", None))
|
||||||
|
.import()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(bundles[0].handler, HandlerKind::Once(_)));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn integration_sse_subscription_streams_responded_events() {
|
async fn integration_sse_subscription_streams_responded_events() {
|
||||||
let sse_body = "data: {\"n\":1}\n\ndata: {\"n\":2}\n\n";
|
let sse_body = "data: {\"n\":1}\n\ndata: {\"n\":2}\n\n";
|
||||||
@@ -1201,10 +1364,67 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let ctx = noop_context("req-12", Capabilities::new());
|
let ctx = noop_context("req-12", Capabilities::new());
|
||||||
let response = (registration.handler)(serde_json::json!({}), ctx).await;
|
let stream = match ®istration.handler {
|
||||||
assert!(response.result.is_ok());
|
HandlerKind::Stream(h) => h(serde_json::json!({}), ctx),
|
||||||
let last = response.result.unwrap();
|
_ => panic!("expected Stream handler"),
|
||||||
assert_eq!(last, serde_json::json!({"n":2}));
|
};
|
||||||
|
let collected: Vec<ResponseEnvelope> = stream.collect().await;
|
||||||
|
assert_eq!(collected.len(), 2);
|
||||||
|
assert_eq!(collected[0].result, Ok(serde_json::json!({"n":1})));
|
||||||
|
assert_eq!(collected[1].result, Ok(serde_json::json!({"n":2})));
|
||||||
|
assert_eq!(collected[0].request_id, "req-12");
|
||||||
|
assert_eq!(collected[1].request_id, "req-12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn integration_sse_subscription_http_error_returns_single_error_envelope() {
|
||||||
|
let base = spawn_echo_server(404, r#"{"error":"missing"}"#, "application/json").await;
|
||||||
|
let spec = OpenAPISpec::from_json(
|
||||||
|
r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/stream":{"post":{"operationId":"stream","responses":{
|
||||||
|
"200":{"content":{"text/event-stream":{"schema":{}}}},
|
||||||
|
"404":{"content":{"application/json":{"schema":{"type":"object"}}}}
|
||||||
|
}}}}}"##,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bundles = adapter(spec, config("svc", &base, None))
|
||||||
|
.import()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let registration = &bundles[0];
|
||||||
|
let ctx = noop_context("req-err", Capabilities::new());
|
||||||
|
let stream = match ®istration.handler {
|
||||||
|
HandlerKind::Stream(h) => h(serde_json::json!({}), ctx),
|
||||||
|
_ => panic!("expected Stream handler"),
|
||||||
|
};
|
||||||
|
let collected: Vec<ResponseEnvelope> = stream.collect().await;
|
||||||
|
assert_eq!(collected.len(), 1);
|
||||||
|
match &collected[0].result {
|
||||||
|
Err(e) => assert_eq!(e.code, "HTTP_404"),
|
||||||
|
other => panic!("expected HTTP_404 error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn integration_query_forwarding_unchanged_single_response() {
|
||||||
|
let base = spawn_echo_server(200, r#"{"ok":true}"#, "application/json").await;
|
||||||
|
let spec = OpenAPISpec::from_json(
|
||||||
|
r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||||
|
"paths":{"/data":{"get":{"operationId":"data","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bundles = adapter(spec, config("svc", &base, None))
|
||||||
|
.import()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let registration = &bundles[0];
|
||||||
|
let ctx = noop_context("req-q", Capabilities::new());
|
||||||
|
let response = match ®istration.handler {
|
||||||
|
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||||
|
_ => panic!("expected Once handler"),
|
||||||
|
};
|
||||||
|
assert_eq!(response.request_id, "req-q");
|
||||||
|
assert_eq!(response.result, Ok(serde_json::json!({"ok":true})));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1447,11 +1667,16 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let ctx = noop_context("req-16", Capabilities::new());
|
let ctx = noop_context("req-16", Capabilities::new());
|
||||||
let response = (registration.handler)(
|
let response = match ®istration.handler {
|
||||||
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
|
HandlerKind::Once(h) => {
|
||||||
ctx,
|
h(
|
||||||
)
|
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
|
||||||
.await;
|
ctx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => panic!("expected Once handler"),
|
||||||
|
};
|
||||||
assert!(
|
assert!(
|
||||||
response.result.is_ok(),
|
response.result.is_ok(),
|
||||||
"expected Ok, got {:?}",
|
"expected Ok, got {:?}",
|
||||||
@@ -1483,7 +1708,10 @@ mod tests {
|
|||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let caps = Capabilities::new().with_http_token("openai", "sk-test-token".to_string());
|
let caps = Capabilities::new().with_http_token("openai", "sk-test-token".to_string());
|
||||||
let ctx = noop_context("req-17", caps);
|
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();
|
let captured = rx.await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
captured.headers.get("authorization").unwrap(),
|
captured.headers.get("authorization").unwrap(),
|
||||||
@@ -1519,7 +1747,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let ctx = noop_context("req-18", Capabilities::new());
|
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 {
|
match response.result {
|
||||||
Ok(Value::String(s)) => assert_eq!(s, "hello world"),
|
Ok(Value::String(s)) => assert_eq!(s, "hello world"),
|
||||||
other => panic!("expected String, got {other:?}"),
|
other => panic!("expected String, got {other:?}"),
|
||||||
@@ -1540,7 +1771,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let registration = &bundles[0];
|
let registration = &bundles[0];
|
||||||
let ctx = noop_context("req-19", Capabilities::new());
|
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 {
|
match response.result {
|
||||||
Err(e) => assert_eq!(e.code, "HTTP_500"),
|
Err(e) => assert_eq!(e.code, "HTTP_500"),
|
||||||
other => panic!("expected HTTP_500, got {other:?}"),
|
other => panic!("expected HTTP_500, got {other:?}"),
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ mod tests {
|
|||||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||||
};
|
};
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||||
@@ -502,44 +502,52 @@ mod tests {
|
|||||||
) -> Arc<OperationRegistry> {
|
) -> Arc<OperationRegistry> {
|
||||||
let mut inner = OperationRegistry::new();
|
let mut inner = OperationRegistry::new();
|
||||||
for (name, op_type, acl) in specs {
|
for (name, op_type, acl) in specs {
|
||||||
inner.register(HandlerRegistration::new(
|
inner
|
||||||
external_spec(&name, op_type, acl),
|
.register(HandlerRegistration::new(
|
||||||
make_echo_handler(),
|
external_spec(&name, op_type, acl),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
let inner = Arc::new(inner);
|
let inner = Arc::new(inner);
|
||||||
|
|
||||||
let mut dispatch_registry = OperationRegistry::new();
|
let mut dispatch_registry = OperationRegistry::new();
|
||||||
for op in inner.list_operations() {
|
for op in inner.list_operations() {
|
||||||
dispatch_registry.register(HandlerRegistration::new(
|
dispatch_registry
|
||||||
external_spec(&op.name, op.op_type, op.access_control.clone()),
|
.register(HandlerRegistration::new(
|
||||||
make_echo_handler(),
|
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,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
ScopedPeerEnv::empty().into(),
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
));
|
))
|
||||||
}
|
.unwrap();
|
||||||
dispatch_registry.register(HandlerRegistration::new(
|
dispatch_registry
|
||||||
services_list_spec(),
|
.register(HandlerRegistration::new(
|
||||||
services_list_handler(Arc::clone(&inner)),
|
services_schema_spec(),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
ScopedPeerEnv::empty().into(),
|
None,
|
||||||
Capabilities::new(),
|
ScopedPeerEnv::empty().into(),
|
||||||
));
|
Capabilities::new(),
|
||||||
dispatch_registry.register(HandlerRegistration::new(
|
))
|
||||||
services_schema_spec(),
|
.unwrap();
|
||||||
services_schema_handler(Arc::clone(&inner)),
|
|
||||||
OperationProvenance::Local,
|
|
||||||
None,
|
|
||||||
ScopedPeerEnv::empty().into(),
|
|
||||||
Capabilities::new(),
|
|
||||||
));
|
|
||||||
Arc::new(dispatch_registry)
|
Arc::new(dispatch_registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use alknet_call::protocol::wire::ResponseEnvelope;
|
use alknet_call::protocol::wire::ResponseEnvelope;
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::types::Capabilities;
|
use alknet_core::types::Capabilities;
|
||||||
@@ -539,14 +539,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn register(registry: &mut OperationRegistry, spec: OperationSpec) {
|
fn register(registry: &mut OperationRegistry, spec: OperationSpec) {
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
spec,
|
.register(HandlerRegistration::new(
|
||||||
noop_handler(),
|
spec,
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(noop_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn external_spec(name: &str, errors: Vec<ErrorDefinition>) -> OperationSpec {
|
fn external_spec(name: &str, errors: Vec<ErrorDefinition>) -> OperationSpec {
|
||||||
@@ -1003,22 +1005,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn internal_operations_excluded_from_error_projection() {
|
fn internal_operations_excluded_from_error_projection() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
OperationSpec::new(
|
.register(HandlerRegistration::new(
|
||||||
"internal/op",
|
OperationSpec::new(
|
||||||
OperationType::Query,
|
"internal/op",
|
||||||
Visibility::Internal,
|
OperationType::Query,
|
||||||
json!({}),
|
Visibility::Internal,
|
||||||
json!({}),
|
json!({}),
|
||||||
vec![error("INTERNAL_ERROR", Some(418))],
|
json!({}),
|
||||||
AccessControl::default(),
|
vec![error("INTERNAL_ERROR", Some(418))],
|
||||||
),
|
AccessControl::default(),
|
||||||
noop_handler(),
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(noop_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let spec = to_openapi(®istry);
|
let spec = to_openapi(®istry);
|
||||||
let responses = responses(&spec, PATH_CALL, "post");
|
let responses = responses(&spec, PATH_CALL, "post");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ mod tests {
|
|||||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||||
};
|
};
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::AuthToken;
|
use alknet_core::auth::AuthToken;
|
||||||
@@ -187,45 +187,51 @@ mod tests {
|
|||||||
|
|
||||||
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
OperationSpec::new(
|
.register(HandlerRegistration::new(
|
||||||
name,
|
OperationSpec::new(
|
||||||
OperationType::Query,
|
name,
|
||||||
visibility,
|
OperationType::Query,
|
||||||
serde_json::json!({}),
|
visibility,
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
vec![],
|
serde_json::json!({}),
|
||||||
acl,
|
vec![],
|
||||||
),
|
acl,
|
||||||
make_handler(|input, context| async move {
|
),
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
}),
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
OperationProvenance::Local,
|
})),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> OperationRegistry {
|
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> OperationRegistry {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
services_list_spec(),
|
.register(HandlerRegistration::new(
|
||||||
services_list_handler(Arc::clone(&inner)),
|
services_list_spec(),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
ScopedPeerEnv::empty().into(),
|
None,
|
||||||
Capabilities::new(),
|
ScopedPeerEnv::empty().into(),
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
services_schema_spec(),
|
.unwrap();
|
||||||
services_schema_handler(Arc::clone(&inner)),
|
registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
None,
|
services_schema_spec(),
|
||||||
ScopedPeerEnv::empty().into(),
|
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
ScopedPeerEnv::empty().into(),
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,32 +276,36 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invoke_for_services_list_returns_access_control_filtered_list() {
|
async fn invoke_for_services_list_returns_access_control_filtered_list() {
|
||||||
let mut inner = OperationRegistry::new();
|
let mut inner = OperationRegistry::new();
|
||||||
inner.register(HandlerRegistration::new(
|
inner
|
||||||
external_spec("public/echo", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, context| async move {
|
external_spec("public/echo", AccessControl::default()),
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
}),
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
OperationProvenance::Local,
|
})),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
inner.register(HandlerRegistration::new(
|
))
|
||||||
external_spec(
|
.unwrap();
|
||||||
"admin/secret",
|
inner
|
||||||
AccessControl {
|
.register(HandlerRegistration::new(
|
||||||
required_scopes: vec!["admin".to_string()],
|
external_spec(
|
||||||
..Default::default()
|
"admin/secret",
|
||||||
},
|
AccessControl {
|
||||||
),
|
required_scopes: vec!["admin".to_string()],
|
||||||
make_handler(|input, context| async move {
|
..Default::default()
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
},
|
||||||
}),
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
None,
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
None,
|
})),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let inner = Arc::new(inner);
|
let inner = Arc::new(inner);
|
||||||
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
|
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
@@ -327,16 +337,18 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invoke_for_services_schema_returns_spec_for_known_op() {
|
async fn invoke_for_services_schema_returns_spec_for_known_op() {
|
||||||
let mut inner = OperationRegistry::new();
|
let mut inner = OperationRegistry::new();
|
||||||
inner.register(HandlerRegistration::new(
|
inner
|
||||||
external_spec("fs/readFile", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, context| async move {
|
external_spec("fs/readFile", AccessControl::default()),
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
}),
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
OperationProvenance::Local,
|
})),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let inner = Arc::new(inner);
|
let inner = Arc::new(inner);
|
||||||
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
|
let discovery = Arc::new(registry_with_discovery(Arc::clone(&inner)));
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
@@ -373,16 +385,18 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invoke_for_internal_op_returns_not_found_not_leaked() {
|
async fn invoke_for_internal_op_returns_not_found_not_leaked() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
internal_spec("secret/op", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, context| async move {
|
internal_spec("secret/op", AccessControl::default()),
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
}),
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
OperationProvenance::Local,
|
})),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = dispatch(registry, provider);
|
let dp = dispatch(registry, provider);
|
||||||
@@ -499,16 +513,18 @@ mod tests {
|
|||||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||||
|
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("agent/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, context| async move {
|
external_spec("agent/run", AccessControl::default()),
|
||||||
ResponseEnvelope::ok(context.request_id, input)
|
HandlerKind::Once(make_handler(|input, context| async move {
|
||||||
}),
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
OperationProvenance::Local,
|
})),
|
||||||
Some(authority),
|
OperationProvenance::Local,
|
||||||
Some(scoped.clone()),
|
Some(authority),
|
||||||
caps,
|
Some(scoped.clone()),
|
||||||
));
|
caps,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = dispatch(registry, provider);
|
let dp = dispatch(registry, provider);
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ mod tests {
|
|||||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||||
};
|
};
|
||||||
use alknet_call::registry::registration::{
|
use alknet_call::registry::registration::{
|
||||||
make_handler, HandlerRegistration, OperationProvenance,
|
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||||
};
|
};
|
||||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType};
|
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType};
|
||||||
use alknet_core::auth::{AuthToken, Identity};
|
use alknet_core::auth::{AuthToken, Identity};
|
||||||
@@ -376,46 +376,52 @@ mod tests {
|
|||||||
|
|
||||||
fn registry_with_echo() -> Arc<OperationRegistry> {
|
fn registry_with_echo() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("echo/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec("echo/run", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
|
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec(
|
.register(HandlerRegistration::new(
|
||||||
"admin/run",
|
external_spec(
|
||||||
AccessControl {
|
"admin/run",
|
||||||
required_scopes: vec!["admin".to_string()],
|
AccessControl {
|
||||||
..Default::default()
|
required_scopes: vec!["admin".to_string()],
|
||||||
},
|
..Default::default()
|
||||||
),
|
},
|
||||||
echo_handler(),
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_internal_op() -> Arc<OperationRegistry> {
|
fn registry_with_internal_op() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
internal_spec("secret/op"),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
internal_spec("secret/op"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,37 +430,43 @@ mod tests {
|
|||||||
) -> Arc<OperationRegistry> {
|
) -> Arc<OperationRegistry> {
|
||||||
let mut inner = OperationRegistry::new();
|
let mut inner = OperationRegistry::new();
|
||||||
for op in inner_ops {
|
for op in inner_ops {
|
||||||
inner.register(op);
|
inner.register(op).unwrap();
|
||||||
}
|
}
|
||||||
let inner = Arc::new(inner);
|
let inner = Arc::new(inner);
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
services_list_spec(),
|
.register(HandlerRegistration::new(
|
||||||
services_list_handler(Arc::clone(&inner)),
|
services_list_spec(),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
services_schema_spec(),
|
.unwrap();
|
||||||
services_schema_handler(Arc::clone(&inner)),
|
registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
None,
|
services_schema_spec(),
|
||||||
None,
|
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
for spec in inner.list_operations() {
|
for spec in inner.list_operations() {
|
||||||
let name = spec.name.clone();
|
let name = spec.name.clone();
|
||||||
let reg = inner.registration(&name).unwrap();
|
let reg = inner.registration(&name).unwrap();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
reg.spec.clone(),
|
.register(HandlerRegistration::new(
|
||||||
Arc::clone(®.handler),
|
reg.spec.clone(),
|
||||||
reg.provenance,
|
reg.handler.clone(),
|
||||||
reg.composition_authority.clone(),
|
reg.provenance,
|
||||||
reg.scoped_env.clone(),
|
reg.composition_authority.clone(),
|
||||||
reg.capabilities.clone(),
|
reg.scoped_env.clone(),
|
||||||
));
|
reg.capabilities.clone(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
@@ -572,7 +584,7 @@ mod tests {
|
|||||||
let ops = vec![
|
let ops = vec![
|
||||||
HandlerRegistration::new(
|
HandlerRegistration::new(
|
||||||
external_spec("public/echo", AccessControl::default()),
|
external_spec("public/echo", AccessControl::default()),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -586,7 +598,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -625,7 +637,7 @@ mod tests {
|
|||||||
async fn schema_returns_full_spec_for_authorized_op() {
|
async fn schema_returns_full_spec_for_authorized_op() {
|
||||||
let ops = vec![HandlerRegistration::new(
|
let ops = vec![HandlerRegistration::new(
|
||||||
external_spec("echo/run", AccessControl::default()),
|
external_spec("echo/run", AccessControl::default()),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -657,7 +669,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -709,22 +721,26 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn batch_internal_op_returns_not_found_in_array() {
|
async fn batch_internal_op_returns_not_found_in_array() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
internal_spec("secret/op"),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
internal_spec("secret/op"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
external_spec("echo/run", AccessControl::default()),
|
.unwrap();
|
||||||
echo_handler(),
|
registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
None,
|
external_spec("echo/run", AccessControl::default()),
|
||||||
None,
|
HandlerKind::Once(echo_handler()),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let router = build_router(registry, unused_provider());
|
let router = build_router(registry, unused_provider());
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
@@ -823,14 +839,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn is_internal_op_detects_registered_internal_op() {
|
fn is_internal_op_detects_registered_internal_op() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
internal_spec("secret/op"),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
internal_spec("secret/op"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
assert!(is_internal_op(®istry, "secret/op"));
|
assert!(is_internal_op(®istry, "secret/op"));
|
||||||
assert!(is_internal_op(®istry, "/secret/op"));
|
assert!(is_internal_op(®istry, "/secret/op"));
|
||||||
}
|
}
|
||||||
@@ -838,14 +856,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn is_internal_op_false_for_external_op() {
|
fn is_internal_op_false_for_external_op() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("echo/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
echo_handler(),
|
external_spec("echo/run", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(echo_handler()),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
assert!(!is_internal_op(®istry, "echo/run"));
|
assert!(!is_internal_op(®istry, "echo/run"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,7 +926,7 @@ mod tests {
|
|||||||
let ops = vec![
|
let ops = vec![
|
||||||
HandlerRegistration::new(
|
HandlerRegistration::new(
|
||||||
external_spec("public/echo", AccessControl::default()),
|
external_spec("public/echo", AccessControl::default()),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -920,7 +940,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -953,7 +973,7 @@ mod tests {
|
|||||||
async fn schema_unknown_op_returns_404() {
|
async fn schema_unknown_op_returns_404() {
|
||||||
let ops = vec![HandlerRegistration::new(
|
let ops = vec![HandlerRegistration::new(
|
||||||
external_spec("echo/run", AccessControl::default()),
|
external_spec("echo/run", AccessControl::default()),
|
||||||
echo_handler(),
|
HandlerKind::Once(echo_handler()),
|
||||||
OperationProvenance::Local,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ mod tests {
|
|||||||
use alknet_call::protocol::wire::{EventEnvelope, ResponseEnvelope, EVENT_RESPONDED};
|
use alknet_call::protocol::wire::{EventEnvelope, ResponseEnvelope, EVENT_RESPONDED};
|
||||||
use alknet_call::registry::context::AbortPolicy;
|
use alknet_call::registry::context::AbortPolicy;
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::{Identity, IdentityProvider};
|
use alknet_core::auth::{Identity, IdentityProvider};
|
||||||
@@ -77,14 +77,18 @@ mod tests {
|
|||||||
|
|
||||||
fn echo_registry() -> Arc<alknet_call::registry::registration::OperationRegistry> {
|
fn echo_registry() -> Arc<alknet_call::registry::registration::OperationRegistry> {
|
||||||
let mut registry = alknet_call::registry::registration::OperationRegistry::new();
|
let mut registry = alknet_call::registry::registration::OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("echo/run"),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
external_spec("echo/run"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||||
None,
|
ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
None,
|
})),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +178,9 @@ mod tests {
|
|||||||
assert!(!env.contains("worker/exec"));
|
assert!(!env.contains("worker/exec"));
|
||||||
conn.register_imported(HandlerRegistration::new(
|
conn.register_imported(HandlerRegistration::new(
|
||||||
external_spec("worker/exec"),
|
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,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use alknet_call::registry::env::{OperationEnv, PeerRef};
|
use alknet_call::registry::env::{OperationEnv, PeerRef};
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::{Identity, IdentityProvider};
|
use alknet_core::auth::{Identity, IdentityProvider};
|
||||||
@@ -113,7 +113,9 @@ mod tests {
|
|||||||
) -> HandlerRegistration {
|
) -> HandlerRegistration {
|
||||||
HandlerRegistration::new(
|
HandlerRegistration::new(
|
||||||
external_spec(name, acl),
|
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,
|
OperationProvenance::FromCall,
|
||||||
composition_authority,
|
composition_authority,
|
||||||
None,
|
None,
|
||||||
@@ -123,14 +125,18 @@ mod tests {
|
|||||||
|
|
||||||
fn echo_registry() -> Arc<OperationRegistry> {
|
fn echo_registry() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("echo/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
external_spec("echo/run", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||||
None,
|
ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
None,
|
})),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,9 +460,9 @@ mod tests {
|
|||||||
|
|
||||||
conn.register_imported(HandlerRegistration::new(
|
conn.register_imported(HandlerRegistration::new(
|
||||||
external_spec("ui/dragged", AccessControl::default()),
|
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 }))
|
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "echoed": input }))
|
||||||
}),
|
})),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -654,7 +660,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
conn.register_imported(HandlerRegistration::new(
|
conn.register_imported(HandlerRegistration::new(
|
||||||
subscription_spec("events/stream"),
|
subscription_spec("events/stream"),
|
||||||
handler,
|
HandlerKind::Once(handler),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use alknet_call::registry::env::OperationEnv;
|
use alknet_call::registry::env::OperationEnv;
|
||||||
use alknet_call::registry::registration::{
|
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_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::{AuthToken, Identity};
|
use alknet_core::auth::{AuthToken, Identity};
|
||||||
@@ -330,77 +330,92 @@ mod tests {
|
|||||||
|
|
||||||
fn echo_registry() -> Arc<OperationRegistry> {
|
fn echo_registry() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec("echo/run", AccessControl::default()),
|
.register(HandlerRegistration::new(
|
||||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
external_spec("echo/run", AccessControl::default()),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||||
None,
|
ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
None,
|
})),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
|
fn registry_with_restricted_op() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
external_spec(
|
.register(HandlerRegistration::new(
|
||||||
"admin/run",
|
external_spec(
|
||||||
AccessControl {
|
"admin/run",
|
||||||
required_scopes: vec!["admin".to_string()],
|
AccessControl {
|
||||||
..Default::default()
|
required_scopes: vec!["admin".to_string()],
|
||||||
},
|
..Default::default()
|
||||||
),
|
},
|
||||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||||
None,
|
ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
None,
|
})),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_subscription() -> Arc<OperationRegistry> {
|
fn registry_with_subscription() -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
let count = Arc::new(StdMutex::new(0u32));
|
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);
|
let counter = Arc::clone(&count);
|
||||||
async move {
|
let mut c = counter.lock().unwrap();
|
||||||
let mut c = counter.lock().unwrap();
|
*c += 1;
|
||||||
*c += 1;
|
let value = *c;
|
||||||
let value = *c;
|
futures::stream::iter(vec![ResponseEnvelope::ok(
|
||||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "n": value }))
|
ctx.request_id,
|
||||||
}
|
serde_json::json!({ "n": value }),
|
||||||
|
)])
|
||||||
});
|
});
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
subscription_spec("events/stream"),
|
.register(HandlerRegistration::new(
|
||||||
handler,
|
subscription_spec("events/stream"),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Stream(handler),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> Arc<OperationRegistry> {
|
fn registry_with_discovery(inner: Arc<OperationRegistry>) -> Arc<OperationRegistry> {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
services_list_spec(),
|
.register(HandlerRegistration::new(
|
||||||
services_list_handler(Arc::clone(&inner)),
|
services_list_spec(),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
|
||||||
None,
|
OperationProvenance::Local,
|
||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
None,
|
||||||
));
|
Capabilities::new(),
|
||||||
registry.register(HandlerRegistration::new(
|
))
|
||||||
services_schema_spec(),
|
.unwrap();
|
||||||
services_schema_handler(Arc::clone(&inner)),
|
registry
|
||||||
OperationProvenance::Local,
|
.register(HandlerRegistration::new(
|
||||||
None,
|
services_schema_spec(),
|
||||||
None,
|
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,22 +558,26 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn handle_inbound_envelope_internal_op_yields_not_found() {
|
async fn handle_inbound_envelope_internal_op_yields_not_found() {
|
||||||
let mut registry = OperationRegistry::new();
|
let mut registry = OperationRegistry::new();
|
||||||
registry.register(HandlerRegistration::new(
|
registry
|
||||||
OperationSpec::new(
|
.register(HandlerRegistration::new(
|
||||||
"secret/op",
|
OperationSpec::new(
|
||||||
OperationType::Query,
|
"secret/op",
|
||||||
Visibility::Internal,
|
OperationType::Query,
|
||||||
serde_json::json!({}),
|
Visibility::Internal,
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
vec![],
|
serde_json::json!({}),
|
||||||
AccessControl::default(),
|
vec![],
|
||||||
),
|
AccessControl::default(),
|
||||||
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }),
|
),
|
||||||
OperationProvenance::Local,
|
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||||
None,
|
ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
None,
|
})),
|
||||||
Capabilities::new(),
|
OperationProvenance::Local,
|
||||||
));
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let registry = Arc::new(registry);
|
let registry = Arc::new(registry);
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
let dp = dispatcher(registry, provider);
|
let dp = dispatcher(registry, provider);
|
||||||
@@ -753,19 +772,18 @@ mod tests {
|
|||||||
let dp = dispatcher(registry, provider);
|
let dp = dispatcher(registry, provider);
|
||||||
let conn = Arc::new(CallConnection::new_overlay_only(identity("ws-peer")));
|
let conn = Arc::new(CallConnection::new_overlay_only(identity("ws-peer")));
|
||||||
|
|
||||||
let mut received = Vec::new();
|
let request = EventEnvelope::requested(
|
||||||
for i in 0..3 {
|
"sub-0",
|
||||||
let request = EventEnvelope::requested(
|
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
||||||
format!("sub-{i}"),
|
);
|
||||||
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
let out = handle_inbound_envelope(&dp, &conn, request)
|
||||||
);
|
.await
|
||||||
let out = handle_inbound_envelope(&dp, &conn, request)
|
.expect("response");
|
||||||
.await
|
assert_eq!(out.r#type, EVENT_ERROR);
|
||||||
.expect("response");
|
assert_eq!(
|
||||||
assert_eq!(out.r#type, EVENT_RESPONDED);
|
out.payload.get("code"),
|
||||||
received.push(out.id);
|
Some(&serde_json::json!("INVALID_OPERATION_TYPE"))
|
||||||
}
|
);
|
||||||
assert_eq!(received.len(), 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -868,7 +886,9 @@ mod tests {
|
|||||||
|
|
||||||
conn.register_imported(HandlerRegistration::new(
|
conn.register_imported(HandlerRegistration::new(
|
||||||
external_spec("ui/dragged", AccessControl::default()),
|
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,
|
OperationProvenance::FromCall,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -1044,28 +1064,27 @@ mod tests {
|
|||||||
drive_ws_session(socket, &dp, &conn).await;
|
drive_ws_session(socket, &dp, &conn).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut got = Vec::new();
|
let request = EventEnvelope::requested(
|
||||||
for i in 0..3 {
|
"sub-ws-0",
|
||||||
let request = EventEnvelope::requested(
|
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
||||||
format!("sub-ws-{i}"),
|
);
|
||||||
serde_json::json!({ "operationId": "/events/stream", "input": {} }),
|
client
|
||||||
);
|
.send_binary(serialize_envelope(&request).unwrap())
|
||||||
client
|
.await;
|
||||||
.send_binary(serialize_envelope(&request).unwrap())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let msg = client.recv_timeout(Duration::from_secs(5)).await;
|
let msg = client.recv_timeout(Duration::from_secs(5)).await;
|
||||||
match msg {
|
match msg {
|
||||||
MockMsg::Binary(bytes) => {
|
MockMsg::Binary(bytes) => {
|
||||||
let env: EventEnvelope = serde_json::from_slice(&bytes).unwrap();
|
let env: EventEnvelope = serde_json::from_slice(&bytes).unwrap();
|
||||||
assert_eq!(env.id, format!("sub-ws-{i}"));
|
assert_eq!(env.id, "sub-ws-0");
|
||||||
assert_eq!(env.r#type, EVENT_RESPONDED);
|
assert_eq!(env.r#type, EVENT_ERROR);
|
||||||
got.push(env.id);
|
assert_eq!(
|
||||||
}
|
env.payload.get("code"),
|
||||||
other => panic!("expected binary, got {other:?}"),
|
Some(&serde_json::json!("INVALID_OPERATION_TYPE"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
other => panic!("expected binary, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert_eq!(got.len(), 3);
|
|
||||||
|
|
||||||
client.close().await;
|
client.close().await;
|
||||||
server_handle.await.ok();
|
server_handle.await.ok();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-30
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# Alknet Architecture
|
# 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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-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-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-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):**
|
**Deferred (not active):**
|
||||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-23
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# Call Protocol
|
# 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)
|
- `NOT_FOUND` — operation not in registry (or Internal op called from wire)
|
||||||
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
|
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
|
||||||
- `INVALID_INPUT` — input doesn't match the operation's JSON Schema
|
- `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
|
- `INTERNAL` — handler error, panic, connection failure
|
||||||
- `TIMEOUT` — request timed out (retryable: true)
|
- `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 } }` |
|
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
|
||||||
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
|
| `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
|
### Protocol Operations
|
||||||
|
|
||||||
@@ -405,10 +406,14 @@ The `CallAdapter::handle()` method:
|
|||||||
|
|
||||||
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
|
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
|
||||||
2. For each accepted stream, reads `EventEnvelope` frames using `FrameFramedReader`
|
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`
|
4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
|
||||||
5. Manages `PendingRequestMap` for outgoing calls initiated by the server
|
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:
|
For outgoing calls (server → client), the adapter:
|
||||||
1. Opens a bidirectional stream with `connection.open_bi()`
|
1. Opens a bidirectional stream with `connection.open_bi()`
|
||||||
2. Sends `call.requested` on that stream
|
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` |
|
| 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 |
|
| 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` |
|
| 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
|
## 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-030: PeerEntry and Identity.id decoupling (`PeerId` source)
|
||||||
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
- ADR-032: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
|
||||||
- ADR-034: Outgoing-only X.509 and the three peer roles
|
- 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/`
|
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-28
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call — Client and Adapters
|
# alknet-call — Client and Adapters
|
||||||
@@ -323,8 +323,21 @@ The flow (ADR-017 §3):
|
|||||||
3. For each discovered op, construct a `HandlerRegistration`:
|
3. For each discovered op, construct a `HandlerRegistration`:
|
||||||
- `spec` mirrors the remote op's name (with optional prefix), namespace,
|
- `spec` mirrors the remote op's name (with optional prefix), namespace,
|
||||||
type, schemas, access control.
|
type, schemas, access control.
|
||||||
- `handler` is a forwarding handler: sends `call.requested` through the
|
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-049):
|
||||||
`CallConnection`, awaits `call.responded` (or streams for subscriptions).
|
- `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`
|
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
||||||
(leaf — ADR-022).
|
(leaf — ADR-022).
|
||||||
4. The caller registers the bundles via
|
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 |
|
| 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` |
|
| 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 |
|
| 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` |
|
| 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` |
|
| 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 |
|
| 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
|
status: draft
|
||||||
last_updated: 2026-06-27
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# Operation Registry
|
# Operation Registry
|
||||||
@@ -91,19 +91,75 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
|
|||||||
|
|
||||||
### Handler
|
### 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
|
```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
|
||||||
- `input: Value` — the deserialized `payload` from the `call.requested` event (always `serde_json::Value`)
|
(always `serde_json::Value`)
|
||||||
- `context: OperationContext` — request ID, identity, metadata, env
|
- `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
|
### 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:
|
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
|
- `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, provenance, composition authority, scoped env, capabilities.
|
- `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 result
|
- `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)
|
- `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
|
||||||
|
|
||||||
### Request ID Generation
|
### Request ID Generation
|
||||||
@@ -229,15 +286,23 @@ The registration bundle carries everything the dispatch path needs to construct
|
|||||||
```rust
|
```rust
|
||||||
pub struct HandlerRegistration {
|
pub struct HandlerRegistration {
|
||||||
pub spec: OperationSpec,
|
pub spec: OperationSpec,
|
||||||
pub handler: Handler,
|
pub handler: HandlerKind, // Once or Stream — validated against spec.op_type (ADR-049)
|
||||||
pub provenance: OperationProvenance,
|
pub provenance: OperationProvenance,
|
||||||
pub composition_authority: Option<CompositionAuthority>, // None for leaves
|
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,
|
pub capabilities: Capabilities,
|
||||||
// NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and
|
// NOTE: ADR-028 added `remote_safe: bool` here; ADR-029 supersedes it and
|
||||||
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
|
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
|
||||||
// not a per-op boolean. See ADR-029 §3.
|
// 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
|
#### 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.
|
- `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).
|
- `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
|
```rust
|
||||||
// with_local: Local provenance, full bundle — all 5 args required.
|
// with_local: Local provenance, full bundle — all 5 args required.
|
||||||
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
|
// 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()
|
let registry = OperationRegistryBuilder::new()
|
||||||
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
|
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
|
||||||
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
||||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||||
// Agent handler (Local, composes — authority + scoped env + capabilities)
|
// Agent handler (Local, Subscription — streams call.responded as the
|
||||||
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
|
// 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"]),
|
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
|
||||||
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
|
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
|
||||||
Capabilities::new().with_api_key("google", google_api_key))
|
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.
|
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
|
```rust
|
||||||
/// The composition dispatch trait. A handler composes child operations
|
/// The composition dispatch trait. A handler composes child operations
|
||||||
/// through its `OperationContext.env` (which implements this trait).
|
/// through its `OperationContext.env` (which implements this trait).
|
||||||
@@ -673,10 +743,11 @@ let registry = OperationRegistryBuilder::new()
|
|||||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
||||||
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||||
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
|
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 {
|
.with(HandlerRegistration {
|
||||||
spec: agent_chat_spec(),
|
spec: agent_chat_spec(),
|
||||||
handler: Arc::new(agent_chat_handler),
|
handler: HandlerKind::Stream(Arc::new(agent_chat_streaming_handler)),
|
||||||
provenance: OperationProvenance::Local,
|
provenance: OperationProvenance::Local,
|
||||||
composition_authority: Some(CompositionAuthority::new(
|
composition_authority: Some(CompositionAuthority::new(
|
||||||
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
|
"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.
|
- **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.
|
- **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.
|
- **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
|
## 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) |
|
| 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 |
|
| 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 |
|
| ~~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
|
## 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-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-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-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/`
|
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-01
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# HTTP Adapters — from_openapi and to_openapi
|
# HTTP Adapters — from_openapi and to_openapi
|
||||||
@@ -123,8 +123,8 @@ The adapter:
|
|||||||
|
|
||||||
### Forwarding handler
|
### Forwarding handler
|
||||||
|
|
||||||
The forwarding handler is the `Arc<dyn Handler>` stored in the
|
The forwarding handler is stored in the `HandlerRegistration` as a
|
||||||
`HandlerRegistration`. At call time, it:
|
`HandlerKind` (ADR-049). At call time, it:
|
||||||
|
|
||||||
1. Reads the call input (`serde_json::Value`).
|
1. Reads the call input (`serde_json::Value`).
|
||||||
2. Builds the outbound HTTP request:
|
2. Builds the outbound HTTP request:
|
||||||
@@ -138,15 +138,23 @@ The forwarding handler is the `Arc<dyn Handler>` stored in the
|
|||||||
below).
|
below).
|
||||||
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
|
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
|
||||||
binary — same content-type branching as the TS `createHTTPOperation`),
|
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
|
5. For a `Subscription` (`text/event-stream` response): streams
|
||||||
`call.responded` events as the SSE chunks arrive (same SSE parsing as
|
`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
|
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
|
||||||
HTTP status code (see Error Fidelity below), returns a `CallError`.
|
HTTP status code (see Error Fidelity below), returns a `CallError`.
|
||||||
|
|
||||||
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
|
The handler is opaque to the `CallAdapter` — it's a `HandlerKind` the
|
||||||
the registry dispatches. `alknet-call` never sees `reqwest`.
|
registry dispatches (via `invoke()` for `Once`, `invoke_streaming()` for
|
||||||
|
`Stream`). `alknet-call` never sees `reqwest`.
|
||||||
|
|
||||||
### HTTP client (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
|
`from_openapi` maps OpenAPI non-2xx response status codes to
|
||||||
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
|
`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`,
|
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:
|
`HTTP_` and the status number:
|
||||||
|
|
||||||
```rust
|
```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 |
|
| 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` 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 |
|
| `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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-01
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# HTTP MCP — from_mcp and to_mcp
|
# HTTP MCP — from_mcp and to_mcp
|
||||||
@@ -78,10 +78,12 @@ The adapter:
|
|||||||
namespace prefix is configured — same local-naming sugar as
|
namespace prefix is configured — same local-naming sugar as
|
||||||
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
|
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
|
||||||
- `spec.namespace` = the configured `namespace`.
|
- `spec.namespace` = the configured `namespace`.
|
||||||
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
|
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
|
||||||
spec doesn't have a native streaming/tool-subscription distinction
|
spec doesn't have a native streaming/tool-subscription distinction
|
||||||
— `tools/call` returns a result. If MCP adds a streaming-tool
|
— `tools/call` returns a result. If MCP adds a streaming-tool
|
||||||
extension, a `Subscription` mapping would be added.)
|
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.visibility` = `Internal` (adapter-registered, ADR-015).
|
||||||
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
|
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
|
||||||
- `spec.output_schema` = depends on whether the tool declares
|
- `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
|
registration (the MCP server is a persistent streamable HTTP
|
||||||
endpoint, not a per-call connection).
|
endpoint, not a per-call connection).
|
||||||
|
|
||||||
The handler is opaque to the `CallAdapter` — `Arc<dyn Handler>` the
|
The handler is opaque to the `CallAdapter` — a `HandlerKind::Once`
|
||||||
registry dispatches. `alknet-call` never sees rmcp.
|
wrapping an `Arc<dyn Handler>` that the registry dispatches. `alknet-call`
|
||||||
|
never sees rmcp.
|
||||||
|
|
||||||
### Output handling (structuredContent vs content blocks)
|
### 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
|
(request/response). `Subscription` operations (streaming) are filtered
|
||||||
out of `search` results and cannot be invoked via `call` — MCP tool
|
out of `search` results and cannot be invoked via `call` — MCP tool
|
||||||
calls are request/response by protocol design; streaming subscriptions
|
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
|
#### `to_mcp` service behavior
|
||||||
|
|
||||||
@@ -263,19 +271,19 @@ axum route handlers) are genuinely per-gateway and are not shared.
|
|||||||
|
|
||||||
Research findings
|
Research findings
|
||||||
(`docs/research/alknet-http-gateway-factoring/findings.md`) recommend
|
(`docs/research/alknet-http-gateway-factoring/findings.md`) recommend
|
||||||
extracting a **thin shared spine** (a concrete struct holding
|
extracting a **thin shared spine** (the concrete `GatewayDispatch` struct
|
||||||
`Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
|
holding `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
|
||||||
`resolve + build_context + invoke` method returning a
|
`resolve + build_context + invoke` method returning a `ResponseEnvelope`,
|
||||||
`ResponseEnvelope`), **not** a `GatewayDispatch` trait or gateway
|
named in ADR-049 and extended with `invoke_streaming()` for the streaming
|
||||||
abstraction. The spine is small (~15–30 lines per endpoint), but it is
|
path), **not** a trait or gateway abstraction. The spine is small (~15–30
|
||||||
the one place where a divergence bug (identity resolved differently,
|
lines per endpoint), but it is the one place where a divergence bug
|
||||||
`OperationContext.internal` set inconsistently, `CallError` mapped
|
(identity resolved differently, `OperationContext.internal` set
|
||||||
asymmetrically) would be a security/correctness issue. The
|
inconsistently, `CallError` mapped asymmetrically) would be a
|
||||||
server-integration and wire-framing layers stay per-gateway; a third
|
security/correctness issue. The server-integration and wire-framing layers
|
||||||
gateway (GraphQL, gRPC) is not on the horizon, and if one appears its
|
stay per-gateway; a third gateway (GraphQL, gRPC) is not on the horizon,
|
||||||
server-integration layer needs its own shape anyway. This is an
|
and if one appears its server-integration layer needs its own shape anyway.
|
||||||
implementation factoring note, not an ADR — the decision is internal to
|
This is an implementation factoring note, not an ADR — the decision is
|
||||||
`alknet-http` and does not cross crate boundaries.
|
internal to `alknet-http` and does not cross crate boundaries.
|
||||||
|
|
||||||
### No-Env-Vars
|
### 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 |
|
| 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 |
|
| 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` |
|
| 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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-07-01
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# HTTP Server
|
# 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:
|
`Accept: text/event-stream` on the `POST`). The axum route handler:
|
||||||
|
|
||||||
- Sets `Content-Type: text/event-stream`.
|
- Sets `Content-Type: text/event-stream`.
|
||||||
- For each `call.responded` event, writes an SSE `data:` frame (the
|
- Calls `GatewayDispatch::invoke_streaming()` (ADR-049) — the streaming
|
||||||
event's `output` serialized as JSON).
|
analogue of `invoke()`, returning a `BoxStream<ResponseEnvelope>`. The
|
||||||
- On `call.completed`, closes the SSE stream (normal end).
|
security invariants are identical to `invoke()`: `internal: false`,
|
||||||
- On `call.aborted`, closes the stream with an SSE error event.
|
`forwarded_for: None`, same capabilities, same `scoped_env`, same ACL
|
||||||
- On HTTP client disconnect (detected as the response writer closing),
|
check before dispatch. The two methods diverge only on the return shape
|
||||||
sends `call.aborted` for the in-flight subscription, which cascades
|
(stream vs single envelope).
|
||||||
to descendants per ADR-016.
|
- 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
|
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
|
||||||
([websocket.md](websocket.md)), the subscription projects directly
|
([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
|
onto WebTransport bidirectional streams; see
|
||||||
[webtransport.md](webtransport.md).
|
[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)
|
### One-directional projection (HTTP request/response)
|
||||||
|
|
||||||
The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection**
|
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) |
|
| 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 |
|
| 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 |
|
| 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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted
|
Accepted (amended by ADR-049 — protocol-level code list extended to six)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
|
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
|
||||||
no `error_schemas`. The `call.error` payload (call-protocol.md L128–134)
|
no `error_schemas`. The `call.error` payload (call-protocol.md L128–134)
|
||||||
carries a `code` and `message`, where `code` is one of five infrastructure
|
carries a `code` and `message`, where `code` is one of six infrastructure
|
||||||
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`.
|
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,
|
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
|
reject the wrong dispatch method for the operation type, time out, or hit
|
||||||
machinery (the registry, the adapter), not by operation handlers.
|
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:
|
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`,
|
- `code` — the error code. Either a protocol-level code (`NOT_FOUND`,
|
||||||
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`) or an
|
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
|
||||||
operation-level domain code from `error_schemas` (e.g.,
|
`TIMEOUT`) or an operation-level domain code from `error_schemas` (e.g.,
|
||||||
`FILE_NOT_FOUND`, `RATE_LIMITED`).
|
`FILE_NOT_FOUND`, `RATE_LIMITED`).
|
||||||
- `message` — human-readable error message. Unstructured — for logging and
|
- `message` — human-readable error message. Unstructured — for logging and
|
||||||
debugging, not for programmatic handling. Clients should switch on
|
debugging, not for programmatic handling. Clients should switch on
|
||||||
@@ -182,7 +186,7 @@ optional-array convention.
|
|||||||
|
|
||||||
### 3. Protocol-level vs operation-level error codes
|
### 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:
|
machinery, not by handlers:
|
||||||
|
|
||||||
| Code | Emitted by | Meaning |
|
| Code | Emitted by | Meaning |
|
||||||
@@ -190,6 +194,7 @@ machinery, not by handlers:
|
|||||||
| `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) |
|
| `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) |
|
||||||
| `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated |
|
| `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated |
|
||||||
| `INVALID_INPUT` | Registry | Input doesn't match `input_schema` |
|
| `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 |
|
| `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
|
||||||
| `TIMEOUT` | Adapter | Request timed out |
|
| `TIMEOUT` | Adapter | Request timed out |
|
||||||
|
|
||||||
@@ -242,8 +247,9 @@ accordingly.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
|
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
|
||||||
codes that collide with the five protocol-level codes (`NOT_FOUND`,
|
codes that collide with the six protocol-level codes (`NOT_FOUND`,
|
||||||
`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The adapter prefixes
|
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
|
||||||
|
`TIMEOUT`). The adapter prefixes
|
||||||
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`,
|
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
|
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
|
||||||
a naming convention — the `from_openapi` example above was previously shown
|
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)
|
for OS-level permission issues)
|
||||||
- docs/reviews/001-pre-implementation-architecture-sanity-check.md
|
- docs/reviews/001-pre-implementation-architecture-sanity-check.md
|
||||||
(finding C5, which this ADR resolves)
|
(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
|
- docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
|
||||||
of making failure typed and declared)
|
of making failure typed and declared)
|
||||||
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`
|
- 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
|
status: draft
|
||||||
last_updated: 2026-06-30
|
last_updated: 2026-07-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
@@ -316,7 +316,12 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
- **Status**: resolved
|
- **Status**: resolved
|
||||||
- **Door type**: One-way (wire format), two-way (mapping mechanism)
|
- **Door type**: One-way (wire format), two-way (mapping mechanism)
|
||||||
- **Priority**: high
|
- **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)
|
- **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
|
## 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.
|
system's structure, constraints, or API surface across crates.
|
||||||
- **Cross-references**: ADR-014, ADR-017, ADR-035,
|
- **Cross-references**: ADR-014, ADR-017, ADR-035,
|
||||||
[http-adapters.md](crates/http/http-adapters.md),
|
[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: completed
|
||||||
|
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
|
||||||
|
|
||||||
|
> Branched build_bundles on spec.op_type: Subscription → make_streaming_forwarding_handler (HandlerKind::Stream), Query/Mutation → existing make_forwarding_handler (HandlerKind::Once). Added CallConnection::subscribe_with_payload() mirroring call_with_payload (registers in PendingRequestMap, abort cascade wired). Streaming forwarding handler reuses build_forwarded_payload for forwarded_for + auth_token (ADR-032). composition_authority: None, scoped_env: None for FromCall streaming leaves. Added 7 unit tests covering all branches and forwarding behavior.
|
||||||
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: completed
|
||||||
|
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
|
||||||
|
|
||||||
|
> Added OperationRegistry::invoke_streaming() in crates/alknet-call/src/registry/registration.rs — the streaming dispatch path for Subscription operations. Same visibility + ACL checks as invoke() (provably identical security axis), then dispatches the StreamingHandler and returns its ResponseStream. Pre-handler errors (not-found, forbidden, INVALID_OPERATION_TYPE for non-Subscription ops) yield a single error ResponseEnvelope via stream::once, then end. Added 6 unit tests covering all paths (subscription dispatch, unknown op, query op cross-kind error, internal op from external, ACL denied, internal call using handler_identity).
|
||||||
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: completed
|
||||||
|
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
|
||||||
|
|
||||||
|
> Branched build_registration on op_type: Subscription → make_streaming_handler + forward_stream() (HandlerKind::Stream), Query/Mutation → existing make_handler + forward() (HandlerKind::Once). forward_stream() sends Accept: text/event-stream, streams SSE chunks via stream::unfold over response.bytes_stream(), reusing parse_sse_frames; each data: frame → one ResponseEnvelope::ok(), HTTP error → single ResponseEnvelope::error(), SSE end → ResponseStream ends. Removed stream_subscription() collect-all placeholder. Added 4 tests + updated integration test. 234 tests pass.
|
||||||
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
|
id: http/review-http-final
|
||||||
name: Final review of alknet-http crate — all components, feature gates, pattern consistency
|
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]
|
depends_on: [http/review-http, http/review-websocket, http/review-mcp]
|
||||||
scope: broad
|
scope: broad
|
||||||
risk: low
|
risk: low
|
||||||
@@ -176,4 +176,24 @@ WebSocket, MCP) and verifies the crate as a whole.
|
|||||||
|
|
||||||
## Summary
|
## 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
|
id: http/review-http
|
||||||
name: Review alknet-http server surface + OpenAPI adapters for spec conformance
|
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]
|
depends_on: [http/server/gateway-endpoints, http/adapters/to-openapi, http/adapters/from-openapi, http/server/healthz-decoy]
|
||||||
scope: broad
|
scope: broad
|
||||||
risk: low
|
risk: low
|
||||||
@@ -163,4 +163,15 @@ core of the crate.
|
|||||||
|
|
||||||
## Summary
|
## 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
|
id: http/review-websocket
|
||||||
name: Review WebSocket path for ADR-044/048 conformance (native session, no length prefix, browsers-not-peers)
|
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]
|
depends_on: [http/websocket/connection-overlay]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: low
|
risk: low
|
||||||
@@ -151,4 +151,14 @@ gateway shape, no length prefix).
|
|||||||
|
|
||||||
## Summary
|
## 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