@lovrozagar/honey 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,22 @@
1
+ /* Code generated by honey. DO NOT EDIT. */
2
+
3
+ use crate::errors::Error;
4
+
5
+ /// SdkResult is a generic result container for safe-mode SDK calls (throwOnError=false).
6
+ /// In safe mode, methods return SdkResult<T> instead of T.
7
+ /// The error is embedded in the result — the outer Result retains only transport/parse errors.
8
+ /// E defaults to Error (generic transport/api error) but per-op typed error envelopes
9
+ /// can substitute a narrower type (e.g. SdkResult<User, UsersCreateError>).
10
+ pub struct SdkResult<T, E = Error> {
11
+ pub data: Option<T>,
12
+ pub error: Option<E>,
13
+ pub status: u16,
14
+ pub response: crate::runtime::ResponseMeta,
15
+ }
16
+
17
+ impl<T, E> SdkResult<T, E> {
18
+ /// ok returns true when the result carries no error.
19
+ pub fn ok(&self) -> bool {
20
+ self.error.is_none()
21
+ }
22
+ }
@@ -0,0 +1,547 @@
1
+ /* Code generated by honey. DO NOT EDIT. */
2
+
3
+ #![allow(dead_code)]
4
+
5
+ use crate::errors::{raise_for_status, Error};
6
+ use std::collections::HashMap;
7
+ use std::future::Future;
8
+ use std::pin::Pin;
9
+ use std::sync::Arc;
10
+ use std::time::Duration;
11
+ use tokio::sync::Mutex;
12
+ use tokio_util::sync::CancellationToken;
13
+
14
+ /// OnRequestHook is called before each request is sent.
15
+ /// Receives a mutable RequestContext; can mutate headers, url, or body.
16
+ /// Returning Err short-circuits the request.
17
+ pub type OnRequestHook = Arc<
18
+ dyn Fn(
19
+ &mut RequestContext,
20
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>
21
+ + Send
22
+ + Sync,
23
+ >;
24
+
25
+ /// OnResponseHook is called after each response is received (before error mapping).
26
+ pub type OnResponseHook = Arc<
27
+ dyn Fn(
28
+ &mut ResponseContext,
29
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>
30
+ + Send
31
+ + Sync,
32
+ >;
33
+
34
+ /// RequestContext carries mutable request metadata through OnRequestHook invocations.
35
+ /// `selector` / `is_stale` / `invalidated_by` mirror the TS hook ctx and are
36
+ /// populated from the RequestMeta snapshot taken before the request fires;
37
+ /// zero values when the tracker is disabled.
38
+ pub struct RequestContext {
39
+ pub method: reqwest::Method,
40
+ pub url: String,
41
+ pub path: String,
42
+ pub headers: HashMap<String, String>,
43
+ pub state: Arc<Mutex<HashMap<String, serde_json::Value>>>,
44
+ pub is_retry: bool,
45
+ pub selector: String,
46
+ pub is_stale: bool,
47
+ pub invalidated_by: Vec<String>,
48
+ }
49
+
50
+ /// ResponseContext carries response metadata through OnResponseHook invocations.
51
+ /// `selector` / `is_stale` / `invalidated_by` mirror the RequestContext fields.
52
+ pub struct ResponseContext {
53
+ pub status: u16,
54
+ pub response: Vec<u8>,
55
+ pub headers: reqwest::header::HeaderMap,
56
+ pub state: Arc<Mutex<HashMap<String, serde_json::Value>>>,
57
+ pub is_retry: bool,
58
+ pub selector: String,
59
+ pub is_stale: bool,
60
+ pub invalidated_by: Vec<String>,
61
+ }
62
+
63
+ /// RequestBody represents the different body types a request can carry.
64
+ /// `Stream` carries an owned, single-shot `reqwest::Body` (typically built via
65
+ /// `reqwest::Body::wrap_stream`); like `Multipart`, it cannot be retried on 401
66
+ /// because the underlying body is not `Clone`.
67
+ pub enum RequestBody<'a> {
68
+ None,
69
+ Json(&'a serde_json::Value),
70
+ FormUrl(&'a HashMap<String, String>),
71
+ Multipart(reqwest::multipart::Form),
72
+ Stream(reqwest::Body),
73
+ }
74
+
75
+ /// OnAuthExpiredHook is the async auth-refresh callback type for ClientConfig.
76
+ pub type OnAuthExpiredHook =
77
+ Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<String, Error>> + Send>> + Send + Sync>;
78
+
79
+ /// LogEntry is the payload passed to on_log callbacks. Consumers plug in their
80
+ /// own logger; the SDK never formats, levels, or persists. Status is Option so
81
+ /// None encodes "omitted" (request_start) without colliding with a real 0 status.
82
+ /// error is repr-style String on the "error" event; None otherwise. Field layout
83
+ /// matches the TS/Python/Go LogEntry shape for cross-language consumer parity.
84
+ #[derive(Clone, Debug)]
85
+ pub struct LogEntry {
86
+ pub level: String,
87
+ pub event: String,
88
+ pub operation: String,
89
+ pub duration_ms: i64,
90
+ pub status: Option<i32>,
91
+ pub error: Option<String>,
92
+ }
93
+
94
+ /// OnLogHook is the callback type for lifecycle log events. Shared between
95
+ /// ClientConfig (async) and SyncClientConfig (blocking) — both take the same
96
+ /// synchronous Fn signature (emission is fire-and-forget from either runtime).
97
+ pub type OnLogHook = Arc<dyn Fn(LogEntry) + Send + Sync>;
98
+
99
+ /// ClientConfig holds all configuration for the SDK client.
100
+ #[derive(Clone)]
101
+ pub struct ClientConfig {
102
+ pub base_url: String,
103
+ pub bearer_token: Option<String>,
104
+ pub headers: HashMap<String, String>,
105
+ pub timeout: Duration,
106
+ pub throw_on_error: bool,
107
+ pub stale_max_entries: usize,
108
+ pub invalidation: Option<InvalidationConfig>,
109
+ pub on_auth_expired: Option<OnAuthExpiredHook>,
110
+ pub http_client: Option<reqwest::Client>,
111
+ pub auth_header_name: Option<String>,
112
+ pub auth_header_prefix: Option<String>,
113
+ pub on_request: Vec<OnRequestHook>,
114
+ pub on_response: Vec<OnResponseHook>,
115
+ pub on_log: Option<OnLogHook>,
116
+ pub state: Option<Arc<Mutex<HashMap<String, serde_json::Value>>>>,
117
+ }
118
+
119
+ impl Default for ClientConfig {
120
+ fn default() -> Self {
121
+ ClientConfig {
122
+ base_url: String::new(),
123
+ bearer_token: None,
124
+ headers: HashMap::new(),
125
+ timeout: Duration::from_secs(30),
126
+ throw_on_error: true,
127
+ stale_max_entries: 500,
128
+ invalidation: None,
129
+ on_auth_expired: None,
130
+ http_client: None,
131
+ auth_header_name: None,
132
+ auth_header_prefix: None,
133
+ on_request: Vec::new(),
134
+ on_response: Vec::new(),
135
+ on_log: None,
136
+ state: None,
137
+ }
138
+ }
139
+ }
140
+
141
+ /* emit_log invokes the on_log callback if configured, swallowing any panic so a
142
+ faulty logger cannot break the request path. Mirrors Python try/except and Go
143
+ defer recover. */
144
+ fn emit_log(cfg: &ClientConfig, entry: LogEntry) {
145
+ if let Some(ref hook) = cfg.on_log {
146
+ let h = Arc::clone(hook);
147
+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || h(entry)));
148
+ }
149
+ }
150
+
151
+ /* err_status extracts an HTTP status from a typed Error::Api, returning None for
152
+ non-Api errors (Transport/Json/UrlParse/Canceled/Timeout/AuthExpired/Other). */
153
+ fn err_status(err: &Error) -> Option<i32> {
154
+ if let Error::Api(api) = err {
155
+ Some(api.status() as i32)
156
+ } else {
157
+ None
158
+ }
159
+ }
160
+
161
+ /// InvalidationConfig controls cache invalidation behaviour. `stale_time` is in
162
+ /// milliseconds; ≤0 disables the tracker. `stale_max_entries` caps the LRU map
163
+ /// (default 1000 when 0). `max_sources_per_target` caps the `by` list per stale
164
+ /// entry (default 16 when 0).
165
+ #[derive(Clone, Default)]
166
+ pub struct InvalidationConfig {
167
+ pub stale_time: i64,
168
+ pub stale_max_entries: usize,
169
+ pub max_sources_per_target: usize,
170
+ }
171
+
172
+ /// ResponseMeta is the owned response snapshot embedded in SdkResult.response.
173
+ /// Populated from a completed RequestResult; all fields are Clone-able.
174
+ pub struct ResponseMeta {
175
+ pub status: u16,
176
+ pub headers: reqwest::header::HeaderMap,
177
+ pub url: String,
178
+ pub body: Vec<u8>,
179
+ }
180
+
181
+ /// RequestResult carries the raw bytes, status, headers, and final URL from a completed request.
182
+ #[allow(dead_code)]
183
+ pub(crate) struct RequestResult {
184
+ pub body: Vec<u8>,
185
+ pub status: u16,
186
+ pub headers: reqwest::header::HeaderMap,
187
+ pub url: String,
188
+ }
189
+
190
+ /// do_request executes an HTTP request, injects auth, merges headers, handles
191
+ /// on_auth_expired retry on 401, fires on_request/on_response hooks, and wraps
192
+ /// errors into typed Error variants. Emits on_log lifecycle entries (request_start
193
+ /// once at entry, response_received once for the final response, error on terminal
194
+ /// failure) — matching TS/Python/Go behavior where retry rounds do not double-emit.
195
+ #[allow(clippy::too_many_arguments)]
196
+ pub(crate) async fn do_request(
197
+ client: &reqwest::Client,
198
+ cfg: &ClientConfig,
199
+ method: reqwest::Method,
200
+ raw_url: &str,
201
+ query: &[(String, String)],
202
+ body: RequestBody<'_>,
203
+ call_headers: Option<&HashMap<String, String>>,
204
+ timeout: Option<std::time::Duration>,
205
+ cancel_token: Option<CancellationToken>,
206
+ operation: &str,
207
+ request_meta: Option<&crate::invalidation::RequestMeta>,
208
+ ) -> Result<RequestResult, Error> {
209
+ let start = std::time::Instant::now();
210
+
211
+ /* Pre-flight cancellation check — short-circuit before any work. */
212
+ if let Some(ref tok) = cancel_token {
213
+ if tok.is_cancelled() {
214
+ emit_log(
215
+ cfg,
216
+ LogEntry {
217
+ level: "error".to_string(),
218
+ event: "error".to_string(),
219
+ operation: operation.to_string(),
220
+ duration_ms: start.elapsed().as_millis() as i64,
221
+ status: None,
222
+ error: Some("Canceled".to_string()),
223
+ },
224
+ );
225
+ return Err(Error::Canceled);
226
+ }
227
+ }
228
+
229
+ emit_log(
230
+ cfg,
231
+ LogEntry {
232
+ level: "debug".to_string(),
233
+ event: "request_start".to_string(),
234
+ operation: operation.to_string(),
235
+ duration_ms: 0,
236
+ status: None,
237
+ error: None,
238
+ },
239
+ );
240
+
241
+ /* Reborrow body for retry path (Json/FormUrl hold references so reborrow is valid;
242
+ Multipart + Stream own non-Clone values and cannot be retried — 401 falls through) */
243
+ let retry_body: Option<RequestBody<'_>> = match &body {
244
+ RequestBody::None => Some(RequestBody::None),
245
+ RequestBody::Json(v) => Some(RequestBody::Json(v)),
246
+ RequestBody::FormUrl(m) => Some(RequestBody::FormUrl(m)),
247
+ RequestBody::Multipart(_) => None,
248
+ RequestBody::Stream(_) => None,
249
+ };
250
+
251
+ let mut result = match execute_request(
252
+ client,
253
+ cfg,
254
+ method.clone(),
255
+ raw_url,
256
+ query,
257
+ body,
258
+ call_headers,
259
+ cfg.bearer_token.as_deref(),
260
+ false,
261
+ timeout,
262
+ cancel_token.clone(),
263
+ request_meta,
264
+ )
265
+ .await
266
+ {
267
+ Ok(r) => r,
268
+ Err(e) => {
269
+ emit_log(
270
+ cfg,
271
+ LogEntry {
272
+ level: "error".to_string(),
273
+ event: "error".to_string(),
274
+ operation: operation.to_string(),
275
+ duration_ms: start.elapsed().as_millis() as i64,
276
+ status: err_status(&e),
277
+ error: Some(format!("{:?}", e)),
278
+ },
279
+ );
280
+ return Err(e);
281
+ }
282
+ };
283
+
284
+ if result.status == 401 {
285
+ if let Some(ref hook) = cfg.on_auth_expired {
286
+ if let Some(retry) = retry_body {
287
+ let new_token = match hook().await {
288
+ Ok(t) => t,
289
+ Err(e) => {
290
+ let mapped = Error::AuthExpired(e.to_string());
291
+ emit_log(
292
+ cfg,
293
+ LogEntry {
294
+ level: "error".to_string(),
295
+ event: "error".to_string(),
296
+ operation: operation.to_string(),
297
+ duration_ms: start.elapsed().as_millis() as i64,
298
+ status: err_status(&mapped),
299
+ error: Some(format!("{:?}", mapped)),
300
+ },
301
+ );
302
+ return Err(mapped);
303
+ }
304
+ };
305
+ result = match execute_request(
306
+ client,
307
+ cfg,
308
+ method,
309
+ raw_url,
310
+ query,
311
+ retry,
312
+ call_headers,
313
+ Some(&new_token),
314
+ true,
315
+ timeout,
316
+ cancel_token,
317
+ request_meta,
318
+ )
319
+ .await
320
+ {
321
+ Ok(r) => r,
322
+ Err(e) => {
323
+ emit_log(
324
+ cfg,
325
+ LogEntry {
326
+ level: "error".to_string(),
327
+ event: "error".to_string(),
328
+ operation: operation.to_string(),
329
+ duration_ms: start.elapsed().as_millis() as i64,
330
+ status: err_status(&e),
331
+ error: Some(format!("{:?}", e)),
332
+ },
333
+ );
334
+ return Err(e);
335
+ }
336
+ };
337
+ }
338
+ /* Multipart/Stream 401: Form/Body is not Clone, cannot retry; fall through to error mapping */
339
+ }
340
+ }
341
+
342
+ let status_i32 = result.status as i32;
343
+ let level = if result.status >= 400 { "warn" } else { "info" };
344
+ emit_log(
345
+ cfg,
346
+ LogEntry {
347
+ level: level.to_string(),
348
+ event: "response_received".to_string(),
349
+ operation: operation.to_string(),
350
+ duration_ms: start.elapsed().as_millis() as i64,
351
+ status: Some(status_i32),
352
+ error: None,
353
+ },
354
+ );
355
+
356
+ if let Some(err) = raise_for_status(result.status, result.body.clone()) {
357
+ emit_log(
358
+ cfg,
359
+ LogEntry {
360
+ level: "error".to_string(),
361
+ event: "error".to_string(),
362
+ operation: operation.to_string(),
363
+ duration_ms: start.elapsed().as_millis() as i64,
364
+ status: Some(status_i32),
365
+ error: Some(format!("{:?}", err)),
366
+ },
367
+ );
368
+ return Err(err);
369
+ }
370
+
371
+ Ok(result)
372
+ }
373
+
374
+ #[allow(clippy::too_many_arguments)]
375
+ async fn execute_request(
376
+ client: &reqwest::Client,
377
+ cfg: &ClientConfig,
378
+ method: reqwest::Method,
379
+ raw_url: &str,
380
+ query: &[(String, String)],
381
+ body: RequestBody<'_>,
382
+ call_headers: Option<&HashMap<String, String>>,
383
+ bearer_token: Option<&str>,
384
+ is_retry: bool,
385
+ per_call_timeout: Option<std::time::Duration>,
386
+ cancel_token: Option<CancellationToken>,
387
+ request_meta: Option<&crate::invalidation::RequestMeta>,
388
+ ) -> Result<RequestResult, Error> {
389
+ let url = build_url(&cfg.base_url, raw_url, query)?;
390
+ let url_str = url.to_string();
391
+
392
+ /* build merged headers map for hook inspection */
393
+ let mut hook_headers: HashMap<String, String> = cfg.headers.clone();
394
+ if let Some(ch) = call_headers {
395
+ for (k, v) in ch {
396
+ hook_headers.insert(k.clone(), v.clone());
397
+ }
398
+ }
399
+ if let Some(token) = bearer_token {
400
+ if !token.is_empty() {
401
+ let name = cfg.auth_header_name.as_deref().unwrap_or("Authorization");
402
+ let prefix = cfg.auth_header_prefix.as_deref().unwrap_or("Bearer ");
403
+ hook_headers.insert(name.to_string(), format!("{}{}", prefix, token));
404
+ }
405
+ }
406
+
407
+ let state = cfg
408
+ .state
409
+ .clone()
410
+ .unwrap_or_else(|| Arc::new(Mutex::new(HashMap::new())));
411
+
412
+ let (meta_selector, meta_is_stale, meta_by) = match request_meta {
413
+ Some(m) => (m.selector.clone(), m.is_stale, m.invalidated_by.clone()),
414
+ None => (String::new(), false, Vec::new()),
415
+ };
416
+
417
+ let mut req_ctx = RequestContext {
418
+ method: method.clone(),
419
+ url: url_str.clone(),
420
+ path: raw_url.to_string(),
421
+ headers: hook_headers,
422
+ state: Arc::clone(&state),
423
+ is_retry,
424
+ selector: meta_selector.clone(),
425
+ is_stale: meta_is_stale,
426
+ invalidated_by: meta_by.clone(),
427
+ };
428
+
429
+ /* fire on_request hooks */
430
+ for hook in cfg.on_request.iter() {
431
+ hook(&mut req_ctx).await?;
432
+ }
433
+
434
+ let mut req = client.request(req_ctx.method.clone(), &req_ctx.url);
435
+
436
+ /* apply headers from context (already merged above + any hook mutations) */
437
+ for (k, v) in &req_ctx.headers {
438
+ req = req.header(k.as_str(), v.as_str());
439
+ }
440
+
441
+ /* apply body and content-type */
442
+ req = match body {
443
+ RequestBody::Json(v) => {
444
+ req = req.header("Content-Type", "application/json");
445
+ req.json(v)
446
+ }
447
+ RequestBody::FormUrl(m) => req.form(m),
448
+ RequestBody::Multipart(f) => req.multipart(f),
449
+ RequestBody::Stream(b) => {
450
+ req = req.header("Content-Type", "application/octet-stream");
451
+ req.body(b)
452
+ }
453
+ RequestBody::None => req,
454
+ };
455
+
456
+ /* apply per-call timeout (overrides cfg.timeout) */
457
+ let timeout = per_call_timeout;
458
+ let effective_timeout = timeout.unwrap_or(cfg.timeout);
459
+ req = req.timeout(effective_timeout);
460
+
461
+ /* Race the send future against cancel_token.cancelled() so external cancellation
462
+ takes priority over an in-flight HTTP send. */
463
+ let resp = match cancel_token.clone() {
464
+ Some(tok) => {
465
+ tokio::select! {
466
+ biased;
467
+ _ = tok.cancelled() => return Err(Error::Canceled),
468
+ r = req.send() => r,
469
+ }
470
+ }
471
+ None => req.send().await,
472
+ }
473
+ .map_err(|e| {
474
+ if e.is_timeout() {
475
+ Error::Timeout
476
+ } else if e.is_request() {
477
+ Error::Canceled
478
+ } else {
479
+ Error::Transport(e)
480
+ }
481
+ })?;
482
+
483
+ let status = resp.status().as_u16();
484
+ let resp_headers = resp.headers().clone();
485
+ let resp_url = resp.url().to_string();
486
+ let bytes = match cancel_token {
487
+ Some(tok) => {
488
+ tokio::select! {
489
+ biased;
490
+ _ = tok.cancelled() => return Err(Error::Canceled),
491
+ r = resp.bytes() => r,
492
+ }
493
+ }
494
+ None => resp.bytes().await,
495
+ }
496
+ .map_err(|e| {
497
+ if e.is_timeout() {
498
+ Error::Timeout
499
+ } else {
500
+ Error::Transport(e)
501
+ }
502
+ })?;
503
+ let body_bytes = bytes.to_vec();
504
+
505
+ let mut resp_ctx = ResponseContext {
506
+ status,
507
+ response: body_bytes.clone(),
508
+ headers: resp_headers.clone(),
509
+ state: Arc::clone(&state),
510
+ is_retry,
511
+ selector: meta_selector,
512
+ is_stale: meta_is_stale,
513
+ invalidated_by: meta_by,
514
+ };
515
+
516
+ /* fire on_response hooks */
517
+ for hook in cfg.on_response.iter() {
518
+ hook(&mut resp_ctx).await?;
519
+ }
520
+
521
+ Ok(RequestResult {
522
+ body: body_bytes,
523
+ status,
524
+ headers: resp_headers,
525
+ url: resp_url,
526
+ })
527
+ }
528
+
529
+ /// build_url constructs the full URL from base + path + query params.
530
+ #[allow(dead_code)]
531
+ pub(crate) fn build_url(
532
+ base_url: &str,
533
+ path: &str,
534
+ query: &[(String, String)],
535
+ ) -> Result<url::Url, Error> {
536
+ let base = url::Url::parse(base_url)?;
537
+ let joined = base.join(path)?;
538
+ let mut url = joined;
539
+ if !query.is_empty() {
540
+ let mut pairs = url.query_pairs_mut();
541
+ for (k, v) in query {
542
+ pairs.append_pair(k, v);
543
+ }
544
+ }
545
+ Ok(url)
546
+ }
547
+