diff --git a/Cargo.lock b/Cargo.lock index 910311f..5836d70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,6 +384,7 @@ dependencies = [ name = "crank-adapter-rest" version = "0.1.0" dependencies = [ + "async-trait", "axum", "crank-core", "futures-util", diff --git a/TASKS.md b/TASKS.md index 7d31b0c..1658d58 100644 --- a/TASKS.md +++ b/TASKS.md @@ -150,5 +150,6 @@ Progress: - Phase 0 / task `0.6`: введены `AuditSink`, `NoopAuditSink`, `AuditEventId` и базовые audit-типы в public seam `crank_core::ext::audit` - Phase 0 / task `0.7`: введены `CapabilityProfile` и `CommunityCapabilityProfile`, а `admin-api` capability payload теперь собирается через public seam вместо локального literal - Phase 0 / task `0.8`: введены `ProtocolAdapter`, `ProtocolAdapterError`, `AdapterRegistry` и базовые transport-типы (`PreparedRequest`, `AdapterResponse`, `WindowExecutionResult`, `RuntimeRequestContext`) в `crank_core::ext::protocol` + - Phase 0 / task `0.9`: `crank-adapter-rest::RestAdapter` реализует новый `ProtocolAdapter` contract, а seam дополнен явным `Target` и window parameters для реального adapter wiring - Phase 0 / task `0.13`: введены `RegistryExtension`, `ExtensionMigration` и `apply_extension_migrations(...)` в `crank-registry` как отдельный public seam для additive private migrations - backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено diff --git a/crates/crank-adapter-rest/Cargo.toml b/crates/crank-adapter-rest/Cargo.toml index 9e78916..fd01f67 100644 --- a/crates/crank-adapter-rest/Cargo.toml +++ b/crates/crank-adapter-rest/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +async-trait = "0.1" crank-core = { path = "../crank-core" } futures-util = "0.3" reqwest = { workspace = true, features = ["stream"] } diff --git a/crates/crank-adapter-rest/src/lib.rs b/crates/crank-adapter-rest/src/lib.rs index 94ee256..6427263 100644 --- a/crates/crank-adapter-rest/src/lib.rs +++ b/crates/crank-adapter-rest/src/lib.rs @@ -3,6 +3,121 @@ mod error; mod model; mod sse; +use async_trait::async_trait; +use crank_core::{ + AdapterResponse, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter, + ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target, WindowExecutionResult, +}; +use serde_json::{Map, Value}; + pub use client::RestAdapter; pub use error::RestAdapterError; pub use model::{RestRequest, RestResponse, RestWindowRequest, RestWindowResponse}; + +#[async_trait] +impl ProtocolAdapter for RestAdapter { + fn protocol(&self) -> Protocol { + Protocol::Rest + } + + fn supports_mode(&self, mode: ExecutionMode) -> bool { + matches!(mode, ExecutionMode::Unary | ExecutionMode::Window) + } + + async fn invoke_unary( + &self, + target: &Target, + prepared: &PreparedRequest, + _context: &RuntimeRequestContext, + ) -> Result { + let target = rest_target(target)?; + let request = RestRequest { + path_params: prepared.path_params.clone(), + query_params: prepared.query_params.clone(), + headers: prepared.headers.clone(), + body: prepared.body.clone(), + timeout_ms: 30_000, + }; + let response = self.execute(target, &request).await?; + + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + data: response.body.clone(), + body: response.body, + }) + } + + async fn invoke_window( + &self, + target: &Target, + prepared: &PreparedRequest, + window_duration_ms: u64, + max_items: Option, + _context: &RuntimeRequestContext, + ) -> Result { + let target = rest_target(target)?; + let request = RestWindowRequest { + request: RestRequest { + path_params: prepared.path_params.clone(), + query_params: prepared.query_params.clone(), + headers: prepared.headers.clone(), + body: prepared.body.clone(), + timeout_ms: 30_000, + }, + window_duration_ms, + max_items: max_items.map(|value| value.saturating_add(1)), + }; + let response = self.execute_window(target, &request).await?; + Ok(window_result_from_response(response.body, max_items)) + } +} + +fn rest_target(target: &Target) -> Result<&RestTarget, ProtocolAdapterError> { + match target { + Target::Rest(target) => Ok(target), + other => Err(ProtocolAdapterError::Message(format!( + "rest adapter cannot handle target {other:?}" + ))), + } +} + +fn window_result_from_response(body: Value, max_items: Option) -> WindowExecutionResult { + let done = body.get("done").and_then(Value::as_bool).unwrap_or(true); + let mut items = body + .get("items") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut truncated = false; + let mut has_more = !done; + + if let Some(max_items) = max_items.map(|value| value as usize) { + if items.len() > max_items { + items.truncate(max_items); + truncated = true; + has_more = true; + } + } + + let summary = body + .get("summary") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + let cursor = body.get("cursor").cloned(); + + WindowExecutionResult { + summary, + items, + cursor, + window_complete: !has_more, + truncated, + has_more, + } +} + +impl From for ProtocolAdapterError { + fn from(value: RestAdapterError) -> Self { + ProtocolAdapterError::Message(value.to_string()) + } +} diff --git a/crates/crank-core/src/ext/protocol.rs b/crates/crank-core/src/ext/protocol.rs index a0ea280..144988f 100644 --- a/crates/crank-core/src/ext/protocol.rs +++ b/crates/crank-core/src/ext/protocol.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{ExecutionMode, Protocol, StreamSession}; +use crate::{ExecutionMode, Protocol, StreamSession, Target}; #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct ResponseCacheScope { @@ -112,13 +112,17 @@ pub trait ProtocolAdapter: Send + Sync { async fn invoke_unary( &self, + target: &Target, prepared: &PreparedRequest, context: &RuntimeRequestContext, ) -> Result; async fn invoke_window( &self, + _target: &Target, _prepared: &PreparedRequest, + _window_duration_ms: u64, + _max_items: Option, _context: &RuntimeRequestContext, ) -> Result { Err(ProtocolAdapterError::UnsupportedMode { @@ -129,6 +133,7 @@ pub trait ProtocolAdapter: Send + Sync { async fn open_session( &self, + _target: &Target, _prepared: &PreparedRequest, _context: &RuntimeRequestContext, ) -> Result { @@ -184,7 +189,7 @@ mod tests { AdapterRegistry, AdapterResponse, PreparedRequest, ProtocolAdapter, ProtocolAdapterError, RuntimeRequestContext, }; - use crate::{ExecutionMode, Protocol}; + use crate::{ExecutionMode, HttpMethod, Protocol, RestTarget, Target}; #[derive(Clone)] struct StubAdapter(Protocol); @@ -201,6 +206,7 @@ mod tests { async fn invoke_unary( &self, + _target: &Target, _prepared: &PreparedRequest, _context: &RuntimeRequestContext, ) -> Result { @@ -244,4 +250,26 @@ mod tests { assert_eq!(scope.workspace_key, "ws_01"); assert_eq!(scope.agent_key, "agent_01"); } + + #[tokio::test] + async fn registry_returns_registered_adapter() { + let registry = AdapterRegistry::empty().register(Arc::new(StubAdapter(Protocol::Rest))); + let adapter = registry.get(Protocol::Rest).unwrap(); + + let response = adapter + .invoke_unary( + &Target::Rest(RestTarget { + base_url: "https://example.test".to_owned(), + method: HttpMethod::Get, + path_template: "/health".to_owned(), + static_headers: BTreeMap::new(), + }), + &PreparedRequest::default(), + &RuntimeRequestContext::from_request_id("req_1"), + ) + .await + .unwrap(); + + assert_eq!(response.status_code, 200); + } }