feat: add Pub operation type, HandlerKind::Sink, call.published wire event (ADR-046)
The call protocol had Subscription (server→client streaming) but lacked the directional complement: client→server streaming, where the initiator produces a stream and the responder's handler consumes it. This gap was inherited from the @alkdev/pubsub EventEnvelope prior art, which has subscribe but no wire-level publish. ADR-046 adds the Pub primitive: - OperationType::Pub (client→server streaming) - OperationType::Subscription renamed to Sub (wire: "subscription" → "sub") - SinkHandler type + HandlerKind::Sink variant - PublishStream type alias (Stream<Item = Result<Value, CallError>>) - OperationRegistry::invoke_sink() dispatch path - call.published wire event (sixth event type, additive) - OperationSpec.publish_schema (Option<Value>, validates per-chunk input) - DispatchResult::Sink + SinkDispatch (handler future + chunk channel) - Dispatcher::pump_sink (feeds call.published chunks from wire to handler) - CallConnection::publish() / publish_with_payload() client methods - from_call sink forwarding handler (make_sink_forwarding_handler) - make_sink_handler() helper Fan-out/broker (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. The Pub primitive is the load-bearing piece the broker will compose on. - 23 new tests (366 total, up from 343) - clippy clean, fmt clean Verification: cargo test — 366 passed cargo clippy --all-targets -- -D warnings — clean cargo fmt --check — clean
This commit is contained in:
1 parent
cc470a363a
commit
ea66398c88
12 files changed
+1821
-90
No files matched your search
+392
-32
@@ -11,13 +11,17 @@
|
||||
//! See `docs/architecture/` for the spec.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use crate::core::ownership::OwnershipProvider;
|
||||
use crate::core::types::StreamError;
|
||||
use futures::channel::mpsc;
|
||||
use futures::stream::StreamExt;
|
||||
use futures::SinkExt;
|
||||
use serde_json::Value;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, warn};
|
||||
@@ -31,24 +35,49 @@ use super::wire::{
|
||||
use crate::protocol::adapter::SessionOverlaySource;
|
||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
use crate::registry::env::{LocalOperationEnv, OperationEnv, PeerCompositeEnv};
|
||||
use crate::registry::registration::{OperationRegistry, ResponseStream};
|
||||
use crate::registry::spec::OperationType;
|
||||
use crate::registry::registration::{
|
||||
extract_json_pointer, HandlerKind, OperationRegistry, PublishStream, ResponseStream,
|
||||
};
|
||||
use crate::registry::spec::{AccessResult, OperationType, Visibility};
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SWEEPER_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const PUBLISH_CHANNEL_BUFFER: usize = 64;
|
||||
|
||||
/// 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.
|
||||
/// the registered operation's `op_type` (ADR-021 §6, ADR-046):
|
||||
/// `Query`/`Mutation` produce a single [`ResponseEnvelope`] (`Once`),
|
||||
/// `Sub` produces a [`ResponseStream`] (`Stream`) that `handle_stream`
|
||||
/// pumps to the wire, `Pub` produces a [`SinkDispatch`] (`Sink`) that
|
||||
/// `handle_stream` feeds from 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.
|
||||
/// 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`), the Stream path (each envelope → frame,
|
||||
/// `call.completed` on natural end), and the Sink path (feed
|
||||
/// `call.published` chunks → one `call.responded`) visibly distinct.
|
||||
pub enum DispatchResult {
|
||||
Once(ResponseEnvelope),
|
||||
Stream(ResponseStream),
|
||||
Sink(SinkDispatch),
|
||||
}
|
||||
|
||||
/// The sink dispatch result for a `Pub` operation (ADR-046). Carries:
|
||||
///
|
||||
/// - `handler`: the future returned by `invoke_sink()` — the
|
||||
/// `SinkHandler` consuming the `PublishStream` and producing a single
|
||||
/// `ResponseEnvelope`.
|
||||
/// - `chunk_tx`: the mpsc sender for feeding `call.published` chunks from
|
||||
/// the wire into the handler's `PublishStream`.
|
||||
///
|
||||
/// `handle_stream` reads `call.published` events from the wire, sends
|
||||
/// each chunk's `input` into `chunk_tx`, and when the stream ends
|
||||
/// (`call.completed` or wire read end), drops `chunk_tx` and awaits
|
||||
/// `handler` to get the single `ResponseEnvelope` to write to the wire.
|
||||
pub struct SinkDispatch {
|
||||
pub handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
||||
pub chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DispatchResult {
|
||||
@@ -58,6 +87,7 @@ impl std::fmt::Debug for DispatchResult {
|
||||
DispatchResult::Stream(_) => {
|
||||
f.debug_tuple("Stream").field(&"<ResponseStream>").finish()
|
||||
}
|
||||
DispatchResult::Sink(_) => f.debug_tuple("Sink").field(&"<SinkDispatch>").finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,24 +237,29 @@ impl Dispatcher {
|
||||
ResponseEnvelope::error(
|
||||
String::new(),
|
||||
CallError::internal(
|
||||
"dispatch_requested called on a Subscription op; use the streaming path",
|
||||
"dispatch_requested called on a Sub op; use the streaming path",
|
||||
),
|
||||
)
|
||||
}),
|
||||
DispatchResult::Sink(_) => ResponseEnvelope::error(
|
||||
String::new(),
|
||||
CallError::internal("dispatch_requested called on a Pub op; use the sink 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`).
|
||||
/// operation's `op_type` (ADR-021 §6, ADR-046). `Query`/`Mutation` →
|
||||
/// `invoke()` → [`DispatchResult::Once`]; `Sub` →
|
||||
/// `invoke_streaming()` → [`DispatchResult::Stream`]; `Pub` →
|
||||
/// `invoke_sink()` → [`DispatchResult::Sink`]. Unknown ops and ACL
|
||||
/// failures resolve via the registry's own envelope/error paths.
|
||||
///
|
||||
/// 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`.
|
||||
/// For the `Sub` and `Pub` branches the root context's deadline is
|
||||
/// cleared (`deadline: None`): streaming and publish operations are
|
||||
/// long-running and unbounded — the 30s request/response deadline
|
||||
/// does not apply. The Once branch keeps the deadline from
|
||||
/// `build_root_context`.
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
connection: &Arc<CallConnection>,
|
||||
@@ -245,11 +280,11 @@ impl Dispatcher {
|
||||
|
||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||
|
||||
let is_subscription = self
|
||||
let op_type = self
|
||||
.registry
|
||||
.registration(&operation_name)
|
||||
.map(|r| r.spec.op_type == OperationType::Subscription)
|
||||
.unwrap_or(false);
|
||||
.map(|r| r.spec.op_type)
|
||||
.unwrap_or(OperationType::Query);
|
||||
|
||||
let mut context = self.build_root_context(
|
||||
request_id.clone(),
|
||||
@@ -259,15 +294,87 @@ impl Dispatcher {
|
||||
connection,
|
||||
);
|
||||
|
||||
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)
|
||||
match op_type {
|
||||
OperationType::Query | OperationType::Mutation => {
|
||||
let envelope = self.registry.invoke(&operation_name, input, context).await;
|
||||
DispatchResult::Once(envelope)
|
||||
}
|
||||
OperationType::Sub => {
|
||||
context.deadline = None;
|
||||
let stream = self
|
||||
.registry
|
||||
.invoke_streaming(&operation_name, input, context);
|
||||
DispatchResult::Stream(stream)
|
||||
}
|
||||
OperationType::Pub => {
|
||||
context.deadline = None;
|
||||
let registration = match self.registry.registration(&operation_name) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return DispatchResult::Once(ResponseEnvelope::not_found(
|
||||
request_id.clone(),
|
||||
&operation_name,
|
||||
));
|
||||
}
|
||||
};
|
||||
if registration.spec.visibility == Visibility::Internal && !context.internal {
|
||||
return DispatchResult::Once(ResponseEnvelope::not_found(
|
||||
request_id.clone(),
|
||||
&operation_name,
|
||||
));
|
||||
}
|
||||
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()
|
||||
};
|
||||
let resource_id = registration
|
||||
.spec
|
||||
.resource_id_path
|
||||
.as_ref()
|
||||
.and_then(|path| extract_json_pointer(&input, path));
|
||||
if let AccessResult::Forbidden(message) = acl.check(
|
||||
identity.as_ref(),
|
||||
resource_id.as_deref(),
|
||||
context.ownership.as_deref(),
|
||||
) {
|
||||
return DispatchResult::Once(ResponseEnvelope::forbidden(
|
||||
request_id.clone(),
|
||||
message,
|
||||
));
|
||||
}
|
||||
let sink_handler = match ®istration.handler {
|
||||
HandlerKind::Sink(h) => Arc::clone(h),
|
||||
HandlerKind::Once(_) => {
|
||||
return DispatchResult::Once(ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_sink() called on a Query/Mutation op; use invoke()",
|
||||
),
|
||||
));
|
||||
}
|
||||
HandlerKind::Stream(_) => {
|
||||
return DispatchResult::Once(ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_sink() called on a Sub op; use invoke_streaming()",
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
let (chunk_tx, chunk_rx) =
|
||||
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
||||
let publish_stream: PublishStream = Box::pin(chunk_rx);
|
||||
let handler = (sink_handler)(input, context, publish_stream);
|
||||
DispatchResult::Sink(SinkDispatch {
|
||||
handler: Box::pin(handler),
|
||||
chunk_tx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +430,10 @@ impl Dispatcher {
|
||||
DispatchResult::Stream(stream) => {
|
||||
self.pump_stream(&mut writer, &request_id, stream).await;
|
||||
}
|
||||
DispatchResult::Sink(sink) => {
|
||||
self.pump_sink(&mut reader, &mut writer, request_id, sink)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
EVENT_ABORTED => {
|
||||
@@ -373,6 +484,90 @@ impl Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump a `Pub` operation's sink to the wire (ADR-046): feed
|
||||
/// `call.published` chunks from the wire into the `SinkDispatch`'s
|
||||
/// `chunk_tx`, and when the publish stream ends (`call.completed`
|
||||
/// initiator→responder, or wire read end), drop `chunk_tx` and await
|
||||
/// the handler future to get the single `ResponseEnvelope`, which
|
||||
/// is written to the wire as `call.responded` (or `call.error`).
|
||||
///
|
||||
/// The handler runs concurrently — it consumes the
|
||||
/// `PublishStream` while we feed it. `call.aborted` for this request
|
||||
/// ID (handled by `handle_abort` on another stream) or connection
|
||||
/// close cancels the task and drops the handler future, releasing
|
||||
/// the handler's resources via `Drop` (ADR-020).
|
||||
pub(crate) async fn pump_sink<R, W>(
|
||||
&self,
|
||||
reader: &mut super::wire::FrameFramedReader<R>,
|
||||
writer: &mut super::wire::FrameFramedWriter<W>,
|
||||
request_id: String,
|
||||
sink: SinkDispatch,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let SinkDispatch {
|
||||
handler,
|
||||
mut chunk_tx,
|
||||
} = sink;
|
||||
|
||||
let feed_fut = async {
|
||||
loop {
|
||||
match reader.read_frame().await {
|
||||
Ok(envelope) => {
|
||||
if envelope.id != request_id {
|
||||
debug!(
|
||||
event_type = %envelope.r#type,
|
||||
id = %envelope.id,
|
||||
"ignoring event with mismatched id during sink pump"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match envelope.r#type.as_str() {
|
||||
"call.published" => {
|
||||
let chunk = envelope
|
||||
.payload
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
if chunk_tx.send(Ok(chunk)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
"call.completed" => break,
|
||||
"call.aborted" => {
|
||||
let _ = chunk_tx
|
||||
.send(Err(CallError::internal("publish aborted by initiator")))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
event_type = %envelope.r#type,
|
||||
"ignoring non-published event during sink pump"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(super::wire::FrameError::ConnectionClosed) => break,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "frame read error during sink pump; ending stream");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(chunk_tx);
|
||||
};
|
||||
|
||||
let (feed_result, response) = tokio::join!(feed_fut, handler);
|
||||
let _: () = feed_result;
|
||||
|
||||
let event: EventEnvelope = response.into();
|
||||
if let Err(err) = writer.write_frame(&event).await {
|
||||
warn!(error = %err, "failed to write sink response frame");
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the shared dispatch loop over an established `CallConnection`:
|
||||
/// spawn the pending-entry sweeper, accept bidirectional streams until the
|
||||
/// connection closes, dispatch each stream via `handle_stream`, and fail
|
||||
@@ -985,7 +1180,7 @@ mod tests {
|
||||
fn subscription_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
@@ -1359,4 +1554,169 @@ mod tests {
|
||||
"stream future dropped → Drop guard released handler resources"
|
||||
);
|
||||
}
|
||||
|
||||
// --- ADR-046: Pub/sink dispatch branch --------------------------------
|
||||
|
||||
fn pub_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn registry_with_pub(
|
||||
name: &str,
|
||||
handler: crate::registry::registration::SinkHandler,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec(name, AccessControl::default()),
|
||||
HandlerKind::Sink(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn counting_sink_handler() -> crate::registry::registration::SinkHandler {
|
||||
use crate::registry::registration::make_sink_handler;
|
||||
use futures::stream::StreamExt;
|
||||
make_sink_handler(|_input, ctx, mut stream| async move {
|
||||
let mut count = 0u32;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(_) => count += 1,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "count": count }))
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_pub_returns_sink_result() {
|
||||
let registry = registry_with_pub("fs/upload", counting_sink_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": "/fs/upload",
|
||||
"input": { "path": "/x" },
|
||||
});
|
||||
match dp.dispatch(&conn, "pub-1".to_string(), payload).await {
|
||||
DispatchResult::Sink(sink) => {
|
||||
use futures::SinkExt;
|
||||
let mut tx = sink.chunk_tx;
|
||||
tx.send(Ok(serde_json::json!({"chunk": 1}))).await.unwrap();
|
||||
tx.send(Ok(serde_json::json!({"chunk": 2}))).await.unwrap();
|
||||
drop(tx);
|
||||
let response = sink.handler.await;
|
||||
let out = response.result.expect("ok");
|
||||
assert_eq!(out["count"], serde_json::json!(2));
|
||||
}
|
||||
other => panic!("expected Sink, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_pub_clears_deadline_to_none() {
|
||||
let registry = registry_with_pub("fs/upload", counting_sink_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": "/fs/upload",
|
||||
"input": {},
|
||||
});
|
||||
match dp.dispatch(&conn, "pub-dl".to_string(), payload).await {
|
||||
DispatchResult::Sink(sink) => {
|
||||
drop(sink.chunk_tx);
|
||||
let _ = sink.handler.await;
|
||||
}
|
||||
other => panic!("expected Sink, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_stream_pub_feeds_published_chunks_and_returns_response() {
|
||||
let registry = registry_with_pub("fs/upload", counting_sink_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(
|
||||
"pub-pump-1",
|
||||
serde_json::json!({
|
||||
"operationId": "/fs/upload",
|
||||
"input": { "path": "/x" },
|
||||
}),
|
||||
);
|
||||
let published1 = EventEnvelope::published("pub-pump-1", serde_json::json!({"chunk": 1}));
|
||||
let published2 = EventEnvelope::published("pub-pump-1", serde_json::json!({"chunk": 2}));
|
||||
let completed = EventEnvelope::completed("pub-pump-1");
|
||||
|
||||
let mut frame_buf = Vec::new();
|
||||
frame_buf.extend_from_slice(&encode_frame(&request));
|
||||
frame_buf.extend_from_slice(&encode_frame(&published1));
|
||||
frame_buf.extend_from_slice(&encode_frame(&published2));
|
||||
frame_buf.extend_from_slice(&encode_frame(&completed));
|
||||
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame_buf));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let stream = crate::core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
dp.handle_stream(conn, stream).await;
|
||||
});
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
handle.await.unwrap();
|
||||
assert_eq!(frames.len(), 1, "pub: one response frame, no completed");
|
||||
assert_eq!(frames[0].r#type, EVENT_RESPONDED);
|
||||
assert_eq!(frames[0].id, "pub-pump-1");
|
||||
let output = frames[0].payload.get("output").expect("output field");
|
||||
assert_eq!(output["count"], serde_json::json!(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_stream_pub_unknown_op_returns_error() {
|
||||
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(
|
||||
"pub-missing-1",
|
||||
serde_json::json!({
|
||||
"operationId": "/no/such/pub",
|
||||
"input": {},
|
||||
}),
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let stream = crate::core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
dp.handle_stream(conn, stream).await;
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
assert_eq!(frames.len(), 1, "unknown pub op: single error");
|
||||
assert_eq!(frames[0].r#type, EVENT_ERROR);
|
||||
assert_eq!(frames[0].id, "pub-missing-1");
|
||||
assert_eq!(
|
||||
frames[0].payload.get("code"),
|
||||
Some(&Value::String("NOT_FOUND".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user