1use 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
27type NativeCrossHttp = CrossCuttingHttp<SharedHttpClient, NativeEventSink>;
29
30static WDIO_FAIL_NEXT_POST_CREATE: AtomicBool = AtomicBool::new(false);
32
33pub fn configure_wdio_fail_next_post_create(armed: bool) {
35 WDIO_FAIL_NEXT_POST_CREATE.store(armed, Ordering::SeqCst);
36}
37
38fn 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#[derive(Clone)]
59pub struct NativeHttp {
60 inner: HttpInner,
61 headers: HostHeaderRegistry,
62}
63
64#[derive(Clone)]
65enum HttpInner {
66 Plain(SharedHttpClient),
68 Cross(NativeCrossHttp),
70}
71
72impl NativeHttp {
73 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 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 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 #[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 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 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}