diff --git a/src/adapters/forward.rs b/src/adapters/forward.rs index ae40755..16161a3 100644 --- a/src/adapters/forward.rs +++ b/src/adapters/forward.rs @@ -38,6 +38,29 @@ //! the buffer grows, so the reassembly buffer can never exceed the //! line cap. //! +//! # Streaming payload contract (FWD-17) +//! +//! Each parsed SSE frame becomes one success envelope: +//! +//! - A `data:` payload that is valid JSON surfaces as the decoded value +//! itself — `123` as a number, `"123"` as a string (the raw-text +//! fallback this replaces erased that distinction). This holds even +//! when the frame carries an `event:` name. +//! - Any other payload surfaces as the +//! `{"data": , "event": }` wrapper: the raw +//! text stays field-addressable and an upstream's named-event +//! conventions (`event: error`, `event: ping`, …) are visible on +//! non-JSON frames. The name is the frame's `event:` field (WHATWG +//! last-wins); `null` when the upstream sent none — the spec's +//! implicit `message` default is *not* substituted, so "upstream +//! named it" and "default name" remain distinguishable. +//! - An empty payload is `Null`. +//! +//! Known limitation: a JSON body under a named event surfaces as the +//! decoded value only — the event name is not carried on JSON frames; +//! a non-JSON body under a named event is the only combination where +//! both are visible. +//! //! # Credentials and input routing //! //! The forwarding handler is the no-env-vars credential injection point @@ -56,6 +79,36 @@ //! request header, the declared `GATEWAY_BODY_KEY` property becomes the //! request body, and every other declared key becomes an upstream query //! parameter. +//! +//! # Input routing and path placeholders (FWD-18) +//! +//! A key that matches a `{placeholder}` in the path template is consumed +//! by the path and never also emits as a query parameter, regardless of +//! its value's shape — the placeholder check precedes query routing in +//! the request builder. A placeholder renders exactly one literal path +//! segment, so its value must be a scalar: object/array values fail with +//! `INVALID_INPUT` (structural values have no faithful single-segment +//! rendering; splicing the minified JSON into the path was the +//! pre-decision behavior and is rejected now). +//! +//! # Percent handling in the rendered path (FWD-19) +//! +//! Two different contracts apply, by design: +//! +//! - A `%` arriving inside a *value* is always encoded (`%` → `%25`), +//! so values carrying `%2F` cannot be mistaken for this crate's own +//! escapes and a value can never inject URL structure. +//! - A `%` in *template/base text* survives verbatim. Templates and +//! base URLs are assembly-supplied (ADR-066 trust boundary), so an +//! assembly that writes `/s3%2Fkeys` is presumed to mean a +//! pre-encoded segment for upstreams that route `%2F` differently +//! from `/` — that upstream-semantics choice belongs to the assembly, +//! not this crate. No injection results: the surviving `%2F` still +//! forms a single segment (the origin check plus the two-pass +//! percent-encoding over template text see to that), and the +//! value-side rule above means every bare `%` in a rendered path +//! traces to template text the assembly author wrote. +//! use std::collections::HashMap; use std::sync::Arc; @@ -1608,6 +1661,32 @@ mod tests { } } + /// FWD-19 pin (documented trade-off, not a rejection): literal `%` + /// in template text is preserved as-is, so an assembly-supplied + /// pre-encoded template like `/s3%2Fkeys` reaches the upstream + /// verbatim (most stacks route `%2F` differently from `/` — the + /// assembly layer owns that choice, ADR-066). A `%` arriving in a + /// *value* is always encoded to `%25`, so the only bare `%` in a + /// rendered path is one the template author placed. + #[test] + fn percent_in_template_text_survives_and_percent_in_values_are_always_encoded() { + let url = request_url( + "https://api.example.com", + "/s3%2Fkeys/{name}", + json!({"name": "x"}), + ) + .expect("template text may carry literal percent escapes"); + assert_eq!(url.path(), "/s3%2Fkeys/x"); + + let url = request_url( + "https://api.example.com", + "/files/{name}", + json!({"name": "a%2Fb"}), + ) + .expect("value percent is encoded, not preserved"); + assert_eq!(url.path(), "/files/a%252Fb"); + } + fn ctx_with_capability(namespace: &str, value: String) -> OperationContext { let mut ctx = noop_context(); ctx.capabilities = Capabilities::new().with_http_token(namespace, value);