The #2 gap in alknet-call: discovers the remote peer's External operations via services/list + services/schema and registers them in the connection's Layer 2 overlay as FromCall-provenance leaves with forwarding handlers. The discovery mechanism was already implemented in registry/discovery.rs; from_call is the client-side consumer of that API. src/client/from_call.rs: - from_call(connection, FromCallConfig) -> Result<Vec<HandlerRegistration>, AdapterError>. Calls services/list then services/schema for each op, rebuilds OperationSpec from the schema JSON (parsing op_type, visibility, error_schemas, access_control), constructs a forwarding handler that calls the remote op via CallConnection::call(), and returns FromCall-provenance bundles (composition_authority: None, scoped_env: None, empty capabilities, remote_safe: false per ADR-028 §4). - FromCallConfig { namespace_prefix: Option<String>, operation_filter: Option<HashSet<String>> } with builder methods. - v1 defaults (two-way doors recorded in client-and-adapters.md): - error-on-collision (DC-3/OQ-28): applying the (possibly empty) prefix produces a name already seen -> AdapterError::Conflict, not silent overwrite. - auto-on-reconnect (DC-2/OQ-27): the overlay is per-connection (Layer 2, ADR-024), so re-import on reconnect is naturally scoped; the assembly layer calls from_call immediately after connect(). - Forwarding handler captures an Arc<CallConnection> and, on invocation, calls the remote op and returns its ResponseEnvelope. The parent_request_id participates in the cross-node abort cascade (ADR-016 §6) — if the parent is aborted, the cascade reaches this handler which sends call.aborted to the remote node; cross-node abort is transparent. - Trust is transitive (recorded in spec): a from_call-imported op executes the remote node's code; scoped_env bounds which ops are reachable, not what they do. OperationContext.internal is now pub (was pub(crate)) so downstream consumers (assembly layer, integration tests) can construct contexts for overlay-env dispatch. Tests (207 lib + 2 integration): - Unit: rebuild_spec name/prefix/op_type/visibility/error_schemas/acl; unknown op_type -> SchemaParse; missing op_type -> SchemaParse; FromCallConfig builder; from_call against a mock connection returns DiscoveryFailed (no transport); FromCall provenance + leaf fields + remote_safe false. - Integration (tests/two_node_call.rs): from_call over a real QUIC loopback — CallClient connects, from_call discovers server/echo, registers the bundle in the overlay, and the forwarding handler round-trips an input through the overlay env to the remote op and back. clippy + fmt + test all green. Refs: tasks/call/client/from-call.md Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §3, §6 Refs: docs/architecture/crates/call/client-and-adapters.md §from_call
107 lines
3.7 KiB
Rust
107 lines
3.7 KiB
Rust
//! Client adapters: turn external operation sources (JSON Schema, OpenAPI,
|
|
//! MCP, remote `from_call` peers) into `HandlerRegistration` bundles.
|
|
//!
|
|
//! See `docs/architecture/crates/call/client-and-adapters.md` for the
|
|
//! OperationAdapter trait and the Adapter Location Map, and
|
|
//! `docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`
|
|
//! §5 for the trait contract.
|
|
|
|
mod call_client;
|
|
mod from_call;
|
|
mod from_jsonschema;
|
|
|
|
pub use call_client::{CallClient, CallCredentials, ClientError, RemoteIdentity};
|
|
pub use from_call::{from_call, FromCallConfig};
|
|
pub use from_jsonschema::{from_jsonschema, FromJsonSchema};
|
|
|
|
use crate::registry::registration::HandlerRegistration;
|
|
|
|
/// Errors produced by [`OperationAdapter::import`].
|
|
///
|
|
/// The variant set is the v1 default (two-way-door remainder, OQ-26);
|
|
/// `#[non_exhaustive]` lets downstream adapters (e.g. `alknet-http`'s
|
|
/// `from_openapi`/`from_mcp`) extend without breaking match arms. All
|
|
/// payloads are string messages — kept simple and `Send + Sync` by
|
|
/// construction.
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[non_exhaustive]
|
|
pub enum AdapterError {
|
|
/// `from_call` remote unreachable / `services/list` failed.
|
|
#[error("discovery failed: {message}")]
|
|
DiscoveryFailed { message: String },
|
|
|
|
/// `from_openapi` / `from_jsonschema` couldn't parse the spec.
|
|
#[error("schema parse error: {message}")]
|
|
SchemaParse { message: String },
|
|
|
|
/// Underlying transport error (QUIC for `from_call`, HTTP for adapters).
|
|
#[error("transport error: {message}")]
|
|
Transport { message: String },
|
|
|
|
/// HTTP 401 for `from_openapi`/`from_mcp`, auth rejected for `from_call`.
|
|
#[error("unauthorized: {message}")]
|
|
Unauthorized { message: String },
|
|
|
|
/// Namespace collision in `from_call` (DC-3); reused for other adapters.
|
|
#[error("conflict: {message}")]
|
|
Conflict { message: String },
|
|
}
|
|
|
|
/// Import a set of operations as `HandlerRegistration` bundles.
|
|
///
|
|
/// Async because `from_call` requires async discovery (`services/list` +
|
|
/// `services/schema` over a QUIC connection); sync adapters (e.g.
|
|
/// `from_jsonschema`, `from_openapi` reading a static spec) trivially satisfy
|
|
/// an async trait — their `import()` bodies contain no `.await` points.
|
|
///
|
|
/// See ADR-017 §5 (`docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`)
|
|
/// and `docs/architecture/crates/call/client-and-adapters.md`.
|
|
#[async_trait::async_trait]
|
|
pub trait OperationAdapter: Send + Sync {
|
|
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct OkAdapter;
|
|
|
|
#[async_trait::async_trait]
|
|
impl OperationAdapter for OkAdapter {
|
|
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
|
|
struct ErrAdapter;
|
|
|
|
#[async_trait::async_trait]
|
|
impl OperationAdapter for ErrAdapter {
|
|
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
|
Err(AdapterError::SchemaParse {
|
|
message: "x".into(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ok_adapter_imports_empty() {
|
|
let adapter = OkAdapter;
|
|
match adapter.import().await {
|
|
Ok(bundles) => assert!(bundles.is_empty()),
|
|
Err(e) => panic!("expected Ok, got Err: {e}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn err_adapter_returns_schema_parse() {
|
|
let adapter = ErrAdapter;
|
|
match adapter.import().await {
|
|
Ok(_) => panic!("expected Err"),
|
|
Err(AdapterError::SchemaParse { message }) => assert_eq!(message, "x"),
|
|
Err(other) => panic!("expected SchemaParse, got {other}"),
|
|
}
|
|
}
|
|
}
|