Skip to main content

proxima_primitives/pipe/
request.rs

1#![cfg(feature = "alloc")]
2
3use alloc::collections::BTreeMap;
4use alloc::string::String;
5use alloc::string::ToString;
6use alloc::sync::Arc;
7use alloc::vec::Vec;
8
9use bytes::Bytes;
10#[cfg(feature = "std")]
11use proxima_core::signal::Signal;
12#[cfg(feature = "std")]
13use std::sync::OnceLock;
14
15use proxima_core::time::Instant;
16
17use crate::pipe::body::{ChunkStream, RequestStream, ResponseStream};
18use crate::pipe::capture_surface::CaptureContext;
19use crate::pipe::endpoint::PeerInfo;
20use crate::pipe::header_list::HeaderList;
21use crate::pipe::header_name::HeaderName;
22use crate::pipe::telemetry_surface::{Labels, NoopTelemetry, TelemetryHandle};
23#[cfg(feature = "std")]
24use crate::pipe::upgrade::UpgradeHandler;
25use proxima_core::ProximaError;
26
27// Process-wide shared defaults so `RequestContext::default()` skips per-
28// call heap allocation. Each handle is an Arc clone (atomic increment).
29//
30// Signal is shared by reference but each clone tracks the same
31// cancel state — callers that need an independent token (per-request
32// timeout, deadline cancel) must call `.with_cancel(Signal::new())`
33// explicitly. The default is "never-cancelled" — matches RequestContext's
34// pre-existing semantics where `default()` produced a fresh token nobody
35// signalled.
36#[cfg(feature = "std")]
37static NOOP_TELEMETRY: OnceLock<TelemetryHandle> = OnceLock::new();
38#[cfg(feature = "std")]
39static NEVER_CANCEL: OnceLock<Signal> = OnceLock::new();
40
41#[cfg(feature = "std")]
42fn noop_telemetry() -> TelemetryHandle {
43    NOOP_TELEMETRY
44        .get_or_init(|| Arc::new(NoopTelemetry))
45        .clone()
46}
47
48#[cfg(not(feature = "std"))]
49fn noop_telemetry() -> TelemetryHandle {
50    Arc::new(NoopTelemetry)
51}
52
53#[cfg(feature = "std")]
54fn never_cancel() -> Signal {
55    NEVER_CANCEL.get_or_init(Signal::new).clone()
56}
57
58#[derive(Clone)]
59pub struct RequestContext {
60    pub telemetry: TelemetryHandle,
61    /// Optional request deadline, on the bound timer driver's clock. Build it
62    /// from `proxima_core::time::now()` so the origin agrees with the reader's
63    /// (`src/load.rs` races dispatch against it).
64    pub deadline: Option<Instant>,
65    /// trace identifier — typically W3C traceparent ASCII bytes.
66    /// `Arc<[u8]>` so per-request clones are refcount bumps. Populated by
67    /// [`adopt_trace_context`](Self::adopt_trace_context); proxima-pipe
68    /// carries only this byte form — the typed trace/span identifier parse +
69    /// generate logic lives in `proxima_telemetry::propagation`.
70    pub trace_id: Option<Arc<[u8]>>,
71    /// W3C `baggage` header bytes lifted off the inbound request, re-stamped
72    /// onto the outbound request so application context survives the hop.
73    /// `Arc<[u8]>` so per-request clones are refcount bumps. `None` when the
74    /// inbound request carried no (valid) baggage.
75    pub baggage: Option<Arc<[u8]>>,
76    pub pipe_label: Option<Arc<[u8]>>,
77    pub upstream_label: Option<Arc<[u8]>>,
78    pub extra_labels: Labels,
79    /// path-pattern parameters extracted by the mount router. `{id}` in the
80    /// mount path becomes `path_params["id"]`. empty when the mount has no
81    /// params or the request was not routed through one.
82    pub path_params: BTreeMap<String, String>,
83    /// per-request cancellation token. Only available under std (requires tokio).
84    /// cancelled when the listener observes connection close, deadline expiry,
85    /// or operator-initiated shutdown. child Pipes should call `.child()`
86    /// for sub-operations they need to cancel independently.
87    #[cfg(feature = "std")]
88    pub cancel: Signal,
89    /// per-call sidecar a wrapping RecordUpstream installs when the chain
90    /// is being recorded. `None` when no recording is active — Pipes
91    /// must handle that case explicitly to avoid silently dropping data.
92    pub capture: Option<Arc<dyn CaptureContext>>,
93    /// io_uring upgrade ticket. The io_uring listener mints this per
94    /// request before dispatching; Pipe authors use it with
95    /// `crate::pipe::upgrade::local_slots::install` to register a
96    /// `LocalUpgradeHandler` that runs against the `!Send` socket
97    /// after the listener writes the upgrade response head. `None` on
98    /// the default (tokio) listener path where the Send-bound
99    /// `Response.upgrade` field is the correct seam instead.
100    pub local_upgrade_ticket: Option<u64>,
101    /// Peer address for this request. When PROXY protocol is active
102    /// this is the original client (not the load balancer); without
103    /// PROXY it's the raw socket peer. `None` on core paths
104    /// that don't carry an address (purely in-process Pipe calls,
105    /// for example).
106    pub peer: Option<PeerInfo>,
107}
108
109impl Default for RequestContext {
110    fn default() -> Self {
111        Self {
112            telemetry: noop_telemetry(),
113            deadline: None,
114            trace_id: None,
115            baggage: None,
116            pipe_label: None,
117            upstream_label: None,
118            extra_labels: Labels::empty(),
119            path_params: BTreeMap::new(),
120            #[cfg(feature = "std")]
121            cancel: never_cancel(),
122            capture: None,
123            local_upgrade_ticket: None,
124            peer: None,
125        }
126    }
127}
128
129impl core::fmt::Debug for RequestContext {
130    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
131        let mut debug = formatter.debug_struct("RequestContext");
132        #[cfg(feature = "std")]
133        debug.field("deadline", &self.deadline);
134        debug
135            .field("trace_id", &self.trace_id)
136            .field("baggage", &self.baggage)
137            .field("pipe_label", &self.pipe_label)
138            .field("upstream_label", &self.upstream_label)
139            .field("extra_labels", &self.extra_labels.entries())
140            .finish_non_exhaustive()
141    }
142}
143
144impl RequestContext {
145    #[must_use]
146    pub fn with_telemetry(mut self, telemetry: TelemetryHandle) -> Self {
147        self.telemetry = telemetry;
148        self
149    }
150
151    #[cfg(feature = "std")]
152    #[must_use]
153    pub fn with_cancel(mut self, cancel: Signal) -> Self {
154        self.cancel = cancel;
155        self
156    }
157
158    /// Derive a child scope from this context's `cancel`. Firing the
159    /// child does not affect the parent; firing the parent fires
160    /// every descendant.
161    #[cfg(feature = "std")]
162    #[must_use]
163    pub fn child_signal(&self) -> Signal {
164        self.cancel.child()
165    }
166
167    #[must_use]
168    pub fn with_pipe_label(mut self, label: impl AsRef<[u8]>) -> Self {
169        self.pipe_label = Some(Arc::from(label.as_ref()));
170        self
171    }
172
173    #[must_use]
174    pub fn with_upstream_label(mut self, label: impl AsRef<[u8]>) -> Self {
175        self.upstream_label = Some(Arc::from(label.as_ref()));
176        self
177    }
178
179    #[must_use]
180    pub fn with_peer(mut self, peer: PeerInfo) -> Self {
181        self.peer = Some(peer);
182        self
183    }
184
185    /// Adopt the byte-form trace id + baggage payload a listener already
186    /// established at ingress via
187    /// `proxima_telemetry::propagation::establish_trace_context`. Proxima
188    /// acts as a real hop: that establishment step adopts the inbound trace
189    /// id (or originates a fresh trace id + span id when none is present)
190    /// and PRESERVES the inbound span id rather than discarding it — a span
191    /// later opened with `parent = self.traceparent()` therefore records the
192    /// real inbound span as its `parent_span_id`, so the literal parent
193    /// chain crosses the wire hop instead of only the shared `trace_id`.
194    ///
195    /// `trace_id` replaces the existing value whenever present (the
196    /// establishment step always produces one under `std`); `baggage` only
197    /// overwrites when present, so an already-set baggage value is not
198    /// clobbered by a re-adoption carrying no inbound baggage header.
199    ///
200    /// proxima-pipe carries no trace identifier type (no header parsing, no
201    /// generation) — that logic lives in the telemetry layer's own
202    /// propagation module, which already depends on this crate for
203    /// [`HeaderList`]. This method is the seam that keeps the dependency
204    /// one-directional: the listener (already depending on that layer)
205    /// does the establishment and hands this crate only the resulting bytes.
206    pub fn adopt_trace_context(&mut self, trace_id: Option<Bytes>, baggage: Option<Bytes>) {
207        if let Some(trace_id) = trace_id {
208            self.trace_id = Some(Arc::from(trace_id.as_ref()));
209        }
210        if let Some(baggage) = baggage {
211            self.baggage = Some(Arc::from(baggage.as_ref()));
212        }
213    }
214
215    /// The raw W3C `traceparent` bytes this request carries (this hop's own
216    /// span, restamped by
217    /// [`adopt_trace_context`](Self::adopt_trace_context)) — ready to hand
218    /// straight to `#[proxima::instrument(parent = ...)]` or
219    /// `Recorder::span_from_traceparent`. `None` before `adopt_trace_context`
220    /// runs, or on a request that predates trace context. This is the
221    /// boundary seam: a handler passes `request.context.traceparent()` so its
222    /// span continues the inbound trace instead of opening a fresh root —
223    /// proxima has no ambient "current span" for it to inherit otherwise.
224    #[must_use]
225    pub fn traceparent(&self) -> Option<&[u8]> {
226        self.trace_id.as_deref()
227    }
228
229    /// Stamp the carried `traceparent` + `baggage` onto outbound headers.
230    /// Called at upstream egress. `insert_if_absent` semantics — a value the
231    /// caller already set wins.
232    pub fn inject_propagation(&self, headers: &mut HeaderList) {
233        if let Some(traceparent) = self.trace_id.as_deref() {
234            headers.insert_if_absent(HeaderName::Traceparent, traceparent);
235        }
236        if let Some(baggage) = self.baggage.as_deref() {
237            headers.insert_if_absent(HeaderName::Baggage, baggage);
238        }
239    }
240
241    #[must_use]
242    pub fn metric_labels(&self, additional: &[(&str, &str)]) -> Labels {
243        let mut pairs: Vec<(String, String)> = self.extra_labels.entries().to_vec();
244        if let Some(pipe) = &self.pipe_label {
245            pairs.push(("pipe".into(), String::from_utf8_lossy(pipe).into_owned()));
246        }
247        if let Some(upstream) = &self.upstream_label {
248            pairs.push((
249                "upstream".into(),
250                String::from_utf8_lossy(upstream).into_owned(),
251            ));
252        }
253        for (name, value) in additional {
254            pairs.push(((*name).to_string(), (*value).to_string()));
255        }
256        let pair_refs: Vec<(&str, &str)> = pairs
257            .iter()
258            .map(|(name, value)| (name.as_str(), value.as_str()))
259            .collect();
260        Labels::from_pairs(&pair_refs)
261    }
262}
263
264#[derive(Debug)]
265pub struct Request<P> {
266    /// HTTP method. Standard methods are unit variants (zero-alloc construct +
267    /// integer compare); a non-standard method carries its wire bytes. See
268    /// [`Method`](crate::pipe::method::Method).
269    pub method: crate::pipe::method::Method,
270    /// URL path bytes. percent-decoded form is in here; the listener
271    /// boundary is responsible for normalization.
272    pub path: Bytes,
273    pub query: HeaderList,
274    pub metadata: HeaderList,
275    /// Buffered request payload. `Bytes` is the default wire case.
276    pub payload: P,
277    /// Streamed request body (uploads, WebSocket-inbound relay). `None`
278    /// for the buffered 80% case. Request trailers fold into `metadata`
279    /// at chunked-decode end; cancellation is `context.cancel`.
280    pub stream: Option<RequestStream>,
281    pub context: RequestContext,
282}
283
284impl<P: Clone> Clone for Request<P> {
285    /// Clone a **buffered** request so a caller can build once and re-send many
286    /// times (a load generator). Every field is a cheap copy — `Method`/`Bytes`
287    /// are refcount bumps, `RequestContext` is `Arc`-backed — so a clone-per-send
288    /// avoids the per-send `path` copy + `RequestContext::default()` of a fresh
289    /// build. A streamed body is **not** carried: the clone has `stream: None`
290    /// and the buffered `payload` (a live stream is single-use, not clonable).
291    fn clone(&self) -> Self {
292        debug_assert!(
293            self.stream.is_none(),
294            "Request::clone drops a live stream; clone only buffered requests"
295        );
296        Self {
297            method: self.method.clone(),
298            path: self.path.clone(),
299            query: self.query.clone(),
300            metadata: self.metadata.clone(),
301            payload: self.payload.clone(),
302            stream: None,
303            context: self.context.clone(),
304        }
305    }
306}
307
308impl Request<Bytes> {
309    #[must_use]
310    pub fn builder() -> RequestBuilder {
311        RequestBuilder::default()
312    }
313
314    /// Materialize the payload as `Bytes`, draining a streamed body if one
315    /// is present, and return `(self, bytes)` with the buffered bytes
316    /// installed on `self.payload` for reuse.
317    pub async fn body_bytes(self) -> Result<(Self, Bytes), ProximaError> {
318        let mut this = self;
319        if let Some(stream) = this.stream.take() {
320            // Grab the trailers slot (an Arc) before `collect` consumes the
321            // stream; the chunked decoder populates it at body-end, so it's
322            // readable once the drain completes.
323            #[cfg(feature = "std")]
324            let trailers_slot = stream.trailers_slot().cloned();
325            let bytes = stream.collect().await?;
326            // Request trailers fold into `metadata` at chunked-decode end.
327            #[cfg(feature = "std")]
328            if let Some(slot) = trailers_slot
329                && let Some(trailers) = slot.lock().clone()
330            {
331                for (name, value) in &trailers {
332                    this.metadata
333                        .insert(Bytes::clone(name), Bytes::clone(value));
334                }
335            }
336            this.payload = Bytes::clone(&bytes);
337            return Ok((this, bytes));
338        }
339        let echo = Bytes::clone(&this.payload);
340        Ok((this, echo))
341    }
342
343    /// Uniform chunk-stream view of the request payload: the streamed body
344    /// if present, else a one-chunk stream of the buffered bytes. Lets a
345    /// listener pump any request body without matching on the shape.
346    pub fn into_chunk_stream(self) -> ChunkStream {
347        match self.stream {
348            Some(stream) => stream.into_chunk_stream(),
349            None => {
350                let bytes = self.payload;
351                alloc::boxed::Box::pin(futures::stream::once(async move { Ok(bytes) }))
352            }
353        }
354    }
355
356    /// Drain a [`PartSource`](crate::pipe::part::PartSource) into an owned
357    /// `Request` — the opt-in materialization step of the `Part` model
358    /// (`docs/proxima-pipe/part-source-sink-design.md`). Every
359    /// `Part::Method` / `Part::Path` / `Part::Header` is copied into owned
360    /// storage and every `Part::Chunk` accumulates into the payload; this is
361    /// the allocation cost a handler opts INTO by calling this instead of
362    /// stepping the source itself. `Part::End` stops the drain; a source
363    /// that never yields it is drained to exhaustion. `query` and
364    /// `RequestContext` are not populated — the `Part` model carries neither.
365    #[cfg(feature = "part-source")]
366    #[must_use]
367    pub fn from_source(source: &mut impl crate::pipe::part::PartSource) -> Self {
368        use crate::pipe::part::Part;
369
370        let mut method = crate::pipe::method::Method::default();
371        let mut path = Bytes::new();
372        let mut metadata = HeaderList::new();
373        let mut chunks: Vec<u8> = Vec::new();
374
375        while let Some(part) = source.next() {
376            match part {
377                Part::Method(bytes) => method = crate::pipe::method::Method::from_bytes(bytes),
378                Part::Path(bytes) => path = Bytes::copy_from_slice(bytes),
379                Part::Header(name, value) => {
380                    metadata.insert(name, value);
381                }
382                Part::Chunk(bytes) => chunks.extend_from_slice(bytes),
383                Part::End => break,
384            }
385        }
386
387        Self {
388            method,
389            path,
390            query: HeaderList::new(),
391            metadata,
392            payload: Bytes::from(chunks),
393            stream: None,
394            context: RequestContext::default(),
395        }
396    }
397}
398
399#[derive(Default)]
400pub struct RequestBuilder {
401    method: Option<crate::pipe::method::Method>,
402    path: Option<Bytes>,
403    query: HeaderList,
404    metadata: HeaderList,
405    payload: Option<Bytes>,
406    stream: Option<RequestStream>,
407    context: RequestContext,
408}
409
410impl core::fmt::Debug for RequestBuilder {
411    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
412        formatter
413            .debug_struct("RequestBuilder")
414            .field("method", &self.method)
415            .field("path", &self.path)
416            .field("query", &self.query)
417            .field("metadata", &self.metadata)
418            .field("has_payload", &self.payload.is_some())
419            .field("context", &self.context)
420            .finish()
421    }
422}
423
424impl RequestBuilder {
425    #[must_use]
426    pub fn method(mut self, method: impl Into<crate::pipe::method::Method>) -> Self {
427        self.method = Some(method.into());
428        self
429    }
430
431    #[must_use]
432    pub fn path(mut self, path: impl crate::pipe::header_list::IntoHeaderBytes) -> Self {
433        self.path = Some(path.into_header_bytes());
434        self
435    }
436
437    #[must_use]
438    pub fn header(
439        mut self,
440        name: impl crate::pipe::header_list::IntoHeaderBytes,
441        value: impl crate::pipe::header_list::IntoHeaderBytes,
442    ) -> Self {
443        self.metadata.insert(name, value);
444        self
445    }
446
447    #[must_use]
448    pub fn query_param(
449        mut self,
450        name: impl crate::pipe::header_list::IntoHeaderBytes,
451        value: impl crate::pipe::header_list::IntoHeaderBytes,
452    ) -> Self {
453        self.query.insert(name, value);
454        self
455    }
456
457    #[must_use]
458    pub fn payload(mut self, payload: impl Into<Bytes>) -> Self {
459        self.payload = Some(payload.into());
460        self
461    }
462
463    #[must_use]
464    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
465        self.payload = Some(body.into());
466        self
467    }
468
469    /// Attach a streamed request body.
470    #[must_use]
471    pub fn stream(mut self, stream: RequestStream) -> Self {
472        self.stream = Some(stream);
473        self
474    }
475
476    #[must_use]
477    pub fn context(mut self, context: RequestContext) -> Self {
478        self.context = context;
479        self
480    }
481
482    #[must_use]
483    pub fn telemetry(mut self, telemetry: TelemetryHandle) -> Self {
484        self.context.telemetry = telemetry;
485        self
486    }
487
488    pub fn build(self) -> Result<Request<Bytes>, ProximaError> {
489        let method = self
490            .method
491            .ok_or_else(|| ProximaError::Config("request method required".into()))?;
492        let path = self
493            .path
494            .ok_or_else(|| ProximaError::Config("request path required".into()))?;
495        Ok(Request {
496            method,
497            path,
498            query: self.query,
499            metadata: self.metadata,
500            payload: self.payload.unwrap_or_default(),
501            stream: self.stream,
502            context: self.context,
503        })
504    }
505}
506
507#[derive(Debug)]
508pub struct Response<P> {
509    pub status: u16,
510    pub metadata: HeaderList,
511    /// Buffered response payload. `Bytes` is the default wire case.
512    pub payload: P,
513    /// Streamed response body. `None` for the buffered 80% case.
514    /// Response trailers (RFC 7230 §4.1.2) ride on the `ResponseStream`;
515    /// the listener emits them after the final 0-length chunk.
516    pub stream: Option<ResponseStream>,
517    /// When set, the listener hands the raw socket to this handler
518    /// after writing the response head. Body framing is suppressed
519    /// (no `Transfer-Encoding`, no body bytes) — the next bytes on
520    /// the wire belong to the handler's protocol (CONNECT tunnel,
521    /// WebSocket frames, h2c SETTINGS, …).
522    #[cfg(feature = "std")]
523    pub upgrade: Option<UpgradeHandler>,
524}
525
526impl<P> Response<P> {
527    /// Construct a typed response with an explicit payload value.
528    #[must_use]
529    pub fn typed(status: u16, payload: P) -> Self {
530        Self {
531            status,
532            metadata: HeaderList::new(),
533            payload,
534            stream: None,
535            #[cfg(feature = "std")]
536            upgrade: None,
537        }
538    }
539
540    /// Attach a streamed response body.
541    #[must_use]
542    pub fn with_stream(mut self, stream: ResponseStream) -> Self {
543        self.stream = Some(stream);
544        self
545    }
546
547    /// Attach an upgrade handler. The listener writes this response's
548    /// head, then hands the raw socket to the handler. Typically
549    /// paired with status 200 (CONNECT) or 101 (Upgrade).
550    #[cfg(feature = "std")]
551    #[must_use]
552    pub fn with_upgrade(mut self, handler: UpgradeHandler) -> Self {
553        self.upgrade = Some(handler);
554        self
555    }
556
557    #[must_use]
558    pub fn with_header(
559        mut self,
560        name: impl crate::pipe::header_list::IntoHeaderBytes,
561        value: impl crate::pipe::header_list::IntoHeaderBytes,
562    ) -> Self {
563        self.metadata.insert(name, value);
564        self
565    }
566
567    #[must_use]
568    pub fn is_success(&self) -> bool {
569        (200..300).contains(&self.status)
570    }
571
572    #[must_use]
573    pub fn is_no_data(&self) -> bool {
574        self.status == 204
575    }
576}
577
578impl Response<Bytes> {
579    #[must_use]
580    pub fn new(status: u16) -> Self {
581        Self {
582            status,
583            metadata: HeaderList::new(),
584            payload: Bytes::new(),
585            stream: None,
586            #[cfg(feature = "std")]
587            upgrade: None,
588        }
589    }
590
591    #[must_use]
592    pub fn with_payload(mut self, payload: impl Into<Bytes>) -> Self {
593        self.payload = payload.into();
594        self
595    }
596
597    #[must_use]
598    pub fn with_body(mut self, body: impl Into<Bytes>) -> Self {
599        self.payload = body.into();
600        self
601    }
602
603    /// Construct a streamed response (status 200).
604    #[must_use]
605    pub fn streamed(stream: ResponseStream) -> Self {
606        Self::new(200).with_stream(stream)
607    }
608
609    #[must_use]
610    pub fn ok(payload: impl Into<Bytes>) -> Self {
611        Self::new(200).with_payload(payload)
612    }
613
614    #[must_use]
615    pub fn not_found() -> Self {
616        Self::new(404)
617    }
618
619    #[must_use]
620    pub fn no_data() -> Self {
621        Self::new(204)
622    }
623
624    /// Uniform chunk-stream view of the response payload: the streamed body
625    /// if present, else a one-chunk stream of the buffered bytes. Lets a
626    /// listener pump any response without matching on the shape.
627    pub fn into_chunk_stream(self) -> ChunkStream {
628        match self.stream {
629            Some(stream) => stream.into_chunk_stream(),
630            None => {
631                let bytes = self.payload;
632                alloc::boxed::Box::pin(futures::stream::once(async move { Ok(bytes) }))
633            }
634        }
635    }
636
637    /// Drain the response payload to `Bytes` — the streamed body collected,
638    /// else the buffered bytes. Not cancellation-aware; for that,
639    /// take `self.stream` and call `ResponseStream::collect(Some(&token))`.
640    pub async fn collect_body(self) -> Result<Bytes, ProximaError> {
641        match self.stream {
642            Some(stream) => {
643                stream
644                    .collect(
645                        #[cfg(feature = "std")]
646                        None,
647                    )
648                    .await
649            }
650            None => Ok(self.payload),
651        }
652    }
653}
654
655// `#[proxima::test]` pulls in the `proxima` dev-dependency, which the
656// loom build keeps out of the graph (see
657// `[target.'cfg(not(loom))'.dev-dependencies]` in Cargo.toml); these
658// tests are unrelated to the Notify/watch loom protocol.
659#[cfg(all(test, not(loom)))]
660#[allow(clippy::unwrap_used, clippy::expect_used)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn builder_requires_method() {
666        let outcome = Request::builder().path("/foo").build();
667        assert!(matches!(outcome, Err(ProximaError::Config(_))));
668    }
669
670    #[test]
671    fn builder_requires_path() {
672        let outcome = Request::builder().method("GET").build();
673        assert!(matches!(outcome, Err(ProximaError::Config(_))));
674    }
675
676    #[test]
677    fn builder_assembles_request_with_required_fields() {
678        let request = Request::builder()
679            .method("GET")
680            .path("/users/42")
681            .header("accept", "application/json")
682            .query_param("limit", "10")
683            .build()
684            .expect("builder should succeed");
685        assert_eq!(request.method, "GET");
686        assert_eq!(request.path, "/users/42");
687        assert_eq!(request.metadata.get_str("accept"), Some("application/json"));
688        assert_eq!(request.query.get_str("limit"), Some("10"));
689    }
690
691    #[proxima::test]
692    async fn body_bytes_returns_collected_bytes_and_replaces_body() {
693        let request = Request::builder()
694            .method("POST")
695            .path("/")
696            .body("hello")
697            .build()
698            .expect("builder should succeed");
699        let (request, bytes) = request
700            .body_bytes()
701            .await
702            .expect("body_bytes should succeed");
703        assert_eq!(&bytes[..], b"hello");
704        assert_eq!(
705            &request.payload[..],
706            b"hello",
707            "echo payload should be available for reuse"
708        );
709    }
710
711    #[test]
712    fn response_helpers_set_status() {
713        assert_eq!(Response::ok("body").status, 200);
714        assert_eq!(Response::not_found().status, 404);
715        assert_eq!(Response::no_data().status, 204);
716    }
717
718    #[test]
719    fn is_success_only_2xx() {
720        assert!(Response::new(200).is_success());
721        assert!(Response::new(299).is_success());
722        assert!(!Response::new(199).is_success());
723        assert!(!Response::new(300).is_success());
724    }
725
726    #[test]
727    fn is_no_data_only_204() {
728        assert!(Response::new(204).is_no_data());
729        assert!(!Response::new(200).is_no_data());
730    }
731
732    #[test]
733    fn default_context_is_noop_telemetry_with_empty_labels() {
734        let context = RequestContext::default();
735        assert!(context.deadline.is_none());
736        assert!(context.trace_id.is_none());
737        assert!(context.pipe_label.is_none());
738        assert!(context.extra_labels.is_empty());
739    }
740
741    // Header parsing + trace id generation now live in
742    // `proxima_telemetry::propagation::establish_trace_context` (see its
743    // tests in that crate); these tests exercise the byte-only seam
744    // proxima-pipe still owns: adopting already-established bytes and
745    // stamping them back onto outbound headers.
746
747    #[test]
748    fn adopt_trace_context_sets_trace_id_and_baggage() {
749        let mut context = RequestContext::default();
750        context.adopt_trace_context(
751            Some(Bytes::from_static(
752                b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
753            )),
754            Some(Bytes::from_static(b"userId=alice")),
755        );
756        assert_eq!(
757            context.trace_id.as_deref(),
758            Some(b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".as_slice())
759        );
760        assert_eq!(context.baggage.as_deref(), Some(b"userId=alice".as_slice()));
761    }
762
763    #[test]
764    fn adopt_trace_context_does_not_clobber_baggage_when_absent() {
765        let mut context = RequestContext::default();
766        context.adopt_trace_context(
767            Some(Bytes::from_static(b"trace-a")),
768            Some(Bytes::from_static(b"k=v")),
769        );
770        context.adopt_trace_context(Some(Bytes::from_static(b"trace-b")), None);
771        assert_eq!(context.trace_id.as_deref(), Some(b"trace-b".as_slice()));
772        assert_eq!(
773            context.baggage.as_deref(),
774            Some(b"k=v".as_slice()),
775            "baggage from an earlier adoption survives a re-adoption with no baggage"
776        );
777    }
778
779    #[test]
780    fn inject_propagation_emits_context_traceparent_and_baggage() {
781        let mut context = RequestContext::default();
782        context.adopt_trace_context(
783            Some(Bytes::from_static(
784                b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
785            )),
786            Some(Bytes::from_static(b"k=v")),
787        );
788
789        let mut outbound = HeaderList::new();
790        context.inject_propagation(&mut outbound);
791        assert_eq!(
792            outbound.get_str("traceparent"),
793            Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
794        );
795        assert_eq!(outbound.get_str("baggage"), Some("k=v"));
796    }
797
798    #[test]
799    fn metric_labels_includes_pipe_and_upstream_when_set() {
800        let context = RequestContext::default()
801            .with_pipe_label("echo-cached")
802            .with_upstream_label("origin");
803        let labels = context.metric_labels(&[("status_class", "2xx")]);
804        let entries = labels.entries();
805        assert!(
806            entries
807                .iter()
808                .any(|(name, value)| name == "pipe" && value == "echo-cached")
809        );
810        assert!(
811            entries
812                .iter()
813                .any(|(name, value)| name == "upstream" && value == "origin")
814        );
815        assert!(
816            entries
817                .iter()
818                .any(|(name, value)| name == "status_class" && value == "2xx")
819        );
820    }
821
822    #[test]
823    fn builder_telemetry_overrides_default_noop() {
824        use crate::pipe::telemetry_surface::Telemetry;
825        use core::sync::atomic::{AtomicU64, Ordering};
826
827        struct CountingTelemetry {
828            count: AtomicU64,
829        }
830
831        impl Telemetry for CountingTelemetry {
832            fn counter_inc(&self, _metric: &str, _labels: &Labels, by: u64) {
833                self.count.fetch_add(by, Ordering::Relaxed);
834            }
835            fn gauge_set(&self, _: &str, _: &Labels, _: i64) {}
836            fn histogram_record(&self, _: &str, _: &Labels, _: f64) {}
837        }
838
839        let counting = Arc::new(CountingTelemetry {
840            count: AtomicU64::new(0),
841        });
842        let telemetry: TelemetryHandle = counting.clone();
843        let request = Request::builder()
844            .method("GET")
845            .path("/")
846            .telemetry(telemetry)
847            .build()
848            .expect("builder should succeed");
849        request
850            .context
851            .telemetry
852            .counter_inc("test_counter", &Labels::empty(), 1);
853        assert_eq!(counting.count.load(Ordering::Relaxed), 1);
854    }
855
856    /// Request and Response can be constructed with no std-only features.
857    /// This test exercises the alloc-only code paths available even without
858    /// the std feature.
859    #[test]
860    fn request_and_response_constructible_under_alloc_only() {
861        let request = Request::builder()
862            .method("POST")
863            .path("/items")
864            .header("content-type", "application/json")
865            .build()
866            .expect("request should build");
867        assert_eq!(request.method, "POST");
868        assert_eq!(request.path, "/items");
869
870        let response = Response::new(201)
871            .with_body("created")
872            .with_header("x-id", "42");
873        assert_eq!(response.status, 201);
874        assert_eq!(response.metadata.get_str("x-id"), Some("42"));
875        assert!(response.is_success());
876    }
877
878    /// RequestContext can be constructed and cloned without std features.
879    #[test]
880    fn request_context_clone_and_peer_label_work_without_std() {
881        let context = RequestContext::default()
882            .with_pipe_label("my-pipe")
883            .with_upstream_label("my-upstream");
884        let cloned = context.clone();
885        assert_eq!(cloned.pipe_label, context.pipe_label);
886        assert_eq!(cloned.upstream_label, context.upstream_label);
887    }
888
889    #[test]
890    fn deadline_field_is_none_by_default() {
891        let context = RequestContext::default();
892        assert!(context.deadline.is_none());
893    }
894
895    /// Under std, a cancel signal can be attached and child scopes derived.
896    #[cfg(feature = "std")]
897    #[test]
898    fn cancel_signal_child_derivation_under_std() {
899        let context = RequestContext::default();
900        let child = context.child_signal();
901        // child scope is not fired when parent is not
902        assert!(!child.is_fired());
903    }
904}