Files
alknet/crates/alknet-call/src/client/mod.rs
glm-5.2 a3825f57cf feat(call): from_call adapter — discover + register remote ops (ADR-017 §3)
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
2026-06-26 13:25:13 +00:00

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}"),
}
}
}