Skip to main content

helix_driver_native/
http.rs

1//! Native HTTP thin shell over the shared host client.
2//!
3//! ## P1.5 出站横切层装配点
4//!
5//! HTTP 执行 / 鉴权头注入 / 错误分类的**实现**全在 `helix-driver-host`(native + FFI 共享,
6//! ADR-007)。本 native 壳只做装配:
7//! - 裸出站:[`NativeHttp::with_config`] / [`NativeHttp::from_shared`](无横切,host-cli / 测试)。
8//! - 带横切:[`NativeHttp::with_crosscut`] 把 `SharedHttpClient` 包进
9//!   [`CrossCuttingHttp`](鉴权意图→token 注入 + 业务 outcome observer +
10//!   traceparent 透传)。**装配点就绪,泵接线待 M2**:当前
11//!   host-cli 走裸 `Plain` 出站,横切语义未生效;M2 host 装配层(host-cli / Tauri shell)
12//!   登录态就绪后改调 `with_crosscut` 接横切层(drift-review dbe1b74..7e9f3ca finding①)。
13
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::Arc;
16
17use helix_core::effect::{HttpRequest, HttpResponse};
18use helix_core::ports::HttpRequester;
19use helix_core::PortError;
20use helix_driver_host::{
21    AsyncMetricSink, AuthTokenRegistry, CrossCuttingHttp, HostHeaderRegistry, HostNetworkConfig,
22    HostOtelRuntime, HttpOutcomeObserver, SharedHttpClient,
23};
24
25use crate::event_sink::NativeEventSink;
26
27/// 出站横切层具体类型(native 装配:内层 `SharedHttpClient` + `NativeEventSink` 回报通道)。
28type NativeCrossHttp = CrossCuttingHttp<SharedHttpClient, NativeEventSink>;
29
30/// Candidate-only one-shot failure switch; production callers never arm this test seam.
31static WDIO_FAIL_NEXT_POST_CREATE: AtomicBool = AtomicBool::new(false);
32
33/// Arm or clear the coarse-real candidate's next `/posts/create` failure.
34pub fn configure_wdio_fail_next_post_create(armed: bool) {
35    WDIO_FAIL_NEXT_POST_CREATE.store(armed, Ordering::SeqCst);
36}
37
38/// Consume the candidate fault only for the real post-create write, never for readback/media.
39fn consume_wdio_post_create_fault(req: &HttpRequest) -> bool {
40    if !req.method.eq_ignore_ascii_case("POST") || !req.url.contains("/posts/create") {
41        return false;
42    }
43    WDIO_FAIL_NEXT_POST_CREATE
44        .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
45        .is_ok()
46}
47
48/// Compatibility wrapper for PC/Tauri code.
49///
50/// HTTP execution, header injection, and error classification live in
51/// `helix-driver-host` so native and FFI share one implementation. 横切层(鉴权/401/
52/// outcome observer/trace)可选叠加——`with_crosscut` 装配后,请求经
53/// [`CrossCuttingHttp`],否则裸发。
54///
55/// `headers` 是底层 `SharedHttpClient` 的共享 registry 句柄(Arc clone),无论是否叠加横切层,
56/// `set/remove_global_header`(connectionId / 通用头运行时注入)始终可达——横切层只管鉴权头,
57/// 通用头仍走此 registry(不互相干扰)。
58#[derive(Clone)]
59pub struct NativeHttp {
60    inner: HttpInner,
61    headers: HostHeaderRegistry,
62}
63
64#[derive(Clone)]
65enum HttpInner {
66    /// 裸出站(无横切;host-cli / 单测)。
67    Plain(SharedHttpClient),
68    /// 带横切(鉴权注入 + 401/断网 emit + traceparent 透传)。
69    Cross(NativeCrossHttp),
70}
71
72impl NativeHttp {
73    // 构造唯一入口 = with_config / from_shared / with_crosscut(ADR-010 共享 client)。
74    // 旧 new()/with_timeout() 曾退化为永远 Err 的活墓碑——已删,误用变编译错(D2 drift 收敛)。
75    pub fn with_config(config: HostNetworkConfig) -> Result<Self, PortError> {
76        SharedHttpClient::new(config).map(Self::from_shared)
77    }
78
79    pub fn with_config_and_metric_sink(
80        config: HostNetworkConfig,
81        metrics: Arc<dyn AsyncMetricSink>,
82    ) -> Result<Self, PortError> {
83        SharedHttpClient::new(config)
84            .map(|client| client.with_metric_sink(metrics))
85            .map(Self::from_shared)
86    }
87
88    pub fn from_shared(inner: SharedHttpClient) -> Self {
89        let headers = inner.headers();
90        Self {
91            inner: HttpInner::Plain(inner),
92            headers,
93        }
94    }
95
96    /// 装配出站横切层(P1.5 / M2 接缝③):把裸 `SharedHttpClient` 包进 [`CrossCuttingHttp`],
97    /// 注入业务事件回报通道(`event_sink`)+ 真鉴权 token 源(`auth`,平台壳登录后写入)+
98    /// 业务结果策略(`outcome_observer`)。
99    /// 底层 registry 句柄保留 → 通用头注入路径不受横切层影响。
100    pub fn with_crosscut(
101        client: SharedHttpClient,
102        event_sink: Arc<NativeEventSink>,
103        auth: AuthTokenRegistry,
104        outcome_observer: HttpOutcomeObserver,
105    ) -> Self {
106        let headers = client.headers();
107        Self {
108            inner: HttpInner::Cross(CrossCuttingHttp::new(
109                client,
110                event_sink,
111                auth,
112                outcome_observer,
113            )),
114            headers,
115        }
116    }
117
118    /// host-cli/Tauri composition root 显式注入 OTel;兼容构造仍保持 no-op。
119    pub fn with_crosscut_and_otel(
120        client: SharedHttpClient,
121        event_sink: Arc<NativeEventSink>,
122        auth: AuthTokenRegistry,
123        outcome_observer: HttpOutcomeObserver,
124        otel: HostOtelRuntime,
125    ) -> Self {
126        let headers = client.headers();
127        Self {
128            inner: HttpInner::Cross(
129                CrossCuttingHttp::new(client, event_sink, auth, outcome_observer).with_otel(otel),
130            ),
131            headers,
132        }
133    }
134
135    pub async fn set_global_header(&self, name: &str, value: &str) -> Result<(), PortError> {
136        self.headers.set_header(name, value).await
137    }
138
139    pub async fn remove_global_header(&self, name: &str) -> Result<(), PortError> {
140        self.headers.remove_header(name).await
141    }
142
143    pub fn is_otel_enabled(&self) -> bool {
144        match &self.inner {
145            HttpInner::Plain(_) => false,
146            HttpInner::Cross(inner) => inner.is_otel_enabled(),
147        }
148    }
149}
150
151#[async_trait::async_trait]
152impl HttpRequester for NativeHttp {
153    async fn request(&self, req: HttpRequest) -> Result<HttpResponse, PortError> {
154        if consume_wdio_post_create_fault(&req) {
155            return Err(PortError::Transport(
156                "wdio coarse-real injected posts/create failure".to_string(),
157            ));
158        }
159        match &self.inner {
160            HttpInner::Plain(c) => c.request(req).await,
161            HttpInner::Cross(c) => c.request(req).await,
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use helix_core::effect::HttpRequest;
170    use std::time::Duration;
171
172    #[tokio::test]
173    async fn test_http_client_builds_with_external_config() {
174        let http = NativeHttp::with_config(test_config()).expect("should build client");
175        assert_eq!(http.receiver_count_for_test(), 0);
176    }
177
178    #[tokio::test]
179    async fn test_set_and_remove_global_header() {
180        let http = NativeHttp::with_config(test_config()).expect("build");
181        http.set_global_header("x-custom", "test-value")
182            .await
183            .expect("set header");
184        http.remove_global_header("x-custom")
185            .await
186            .expect("remove header");
187    }
188
189    #[tokio::test]
190    async fn test_invalid_method_returns_err() {
191        let http = NativeHttp::with_config(test_config()).expect("build");
192        let req = HttpRequest {
193            method: "INVALID METHOD!".to_string(),
194            url: "/".to_string(),
195            headers: vec![],
196            body: None,
197        };
198        let result = http.request(req).await;
199        assert!(result.is_err(), "invalid method should return Err");
200    }
201
202    #[tokio::test]
203    async fn test_timeout_produces_transport_error() {
204        let http = NativeHttp::with_config(test_config().with_timeout(Duration::from_millis(10)))
205            .expect("build");
206        let req = HttpRequest {
207            method: "GET".to_string(),
208            url: "http://10.255.255.1:1/never".to_string(),
209            headers: vec![],
210            body: None,
211        };
212        let result = http.request(req).await;
213        assert!(
214            matches!(result, Err(PortError::Transport(_))),
215            "timeout/network should produce PortError::Transport, got {:?}",
216            result
217        );
218    }
219
220    /// P1.5 装配 smoke:with_crosscut 叠加横切层后,通用头注入路径仍可达(registry 句柄保留),
221    /// 且断网请求经横切层产 Transport 错误(横切语义详测在 host `p1_5_http_crosscut_test.rs`)。
222    #[tokio::test]
223    async fn test_with_crosscut_keeps_header_path_and_transport_error() {
224        use crate::event_sink::NativeEventSink;
225        use helix_driver_host::AuthTokenRegistry;
226        use std::sync::Arc;
227
228        let client = SharedHttpClient::new(test_config().with_timeout(Duration::from_millis(10)))
229            .expect("build client");
230        let (sink, _rx) = NativeEventSink::new();
231        let auth = AuthTokenRegistry::new();
232        auth.set_session("cookieId", "u1");
233        let http = NativeHttp::with_crosscut(client, Arc::new(sink), auth, |_, _| None);
234        assert!(!http.is_otel_enabled(), "兼容入口必须保持真 no-op");
235
236        // 通用头注入仍可达(横切叠加后 registry 句柄保留,不再 unreachable)。
237        http.set_global_header("connectionId", "conn-1")
238            .await
239            .expect("set header on crosscut http");
240        http.remove_global_header("connectionId")
241            .await
242            .expect("remove header on crosscut http");
243
244        // 断网走横切层 → Transport 错误原样上抛。
245        let req = HttpRequest {
246            method: "GET".to_string(),
247            url: "http://10.255.255.1:1/never".to_string(),
248            headers: vec![],
249            body: None,
250        };
251        assert!(matches!(
252            http.request(req).await,
253            Err(PortError::Transport(_))
254        ));
255    }
256
257    #[test]
258    fn explicit_crosscut_constructor_installs_enabled_otel() {
259        use helix_driver_host::{HostOtelConfig, HostOtelRuntime};
260
261        let client = SharedHttpClient::new(test_config()).expect("build client");
262        let (sink, _rx) = NativeEventSink::new();
263        let runtime = HostOtelRuntime::new(HostOtelConfig {
264            enabled: true,
265            service_name: "native-http-test".to_string(),
266            endpoint: "noop".to_string(),
267            protocol: "noop".to_string(),
268        });
269        let http = NativeHttp::with_crosscut_and_otel(
270            client,
271            Arc::new(sink),
272            AuthTokenRegistry::new(),
273            |_, _| None,
274            runtime,
275        );
276        assert!(http.is_otel_enabled());
277    }
278
279    fn test_config() -> HostNetworkConfig {
280        HostNetworkConfig::new("http://127.0.0.1", "ws://127.0.0.1")
281    }
282}
283
284impl NativeHttp {
285    #[cfg(test)]
286    fn receiver_count_for_test(&self) -> usize {
287        0
288    }
289}