multistore_cf_workers/request.rs
1//! Request parsing helpers for Cloudflare Workers.
2//!
3//! Provides [`RequestParts`] to extract owned HTTP metadata from a
4//! `web_sys::Request`, and convert it into the borrowed
5//! [`RequestInfo`](multistore::route_handler::RequestInfo) required by the gateway.
6
7use crate::body::JsBody;
8use crate::response::headermap_from_js;
9use http::{HeaderMap, Method, Uri};
10use multistore::route_handler::RequestInfo;
11
12/// Owned HTTP request metadata extracted from a `web_sys::Request`.
13///
14/// Workers passes a `web_sys::Request` with borrowed JS strings and a
15/// `ReadableStream` body. The gateway expects a [`RequestInfo`] that
16/// borrows from Rust-owned data, so this struct bridges the gap by
17/// owning the parsed method, path, query, and headers.
18///
19/// # Example
20///
21/// ```rust,ignore
22/// let (parts, body) = RequestParts::from_web_sys(&req)?;
23/// let result = gateway
24/// .handle_request(&parts.as_request_info(), body, collect_js_body)
25/// .await;
26/// ```
27pub struct RequestParts {
28 /// The HTTP method.
29 pub method: Method,
30 /// The percent-**decoded** URL path (e.g. `"/bucket/my key"`).
31 ///
32 /// Decoded for S3 operation parsing and bucket/key routing. **Do not** use
33 /// this for SigV4 verification: the canonical URI must be the encoded form
34 /// the client signed, so use [`signing_path`](Self::signing_path) instead.
35 pub path: String,
36 /// The raw, percent-**encoded** URL path exactly as it arrived on the wire
37 /// (e.g. `"/bucket/my%20key"`).
38 ///
39 /// This is the form the client signs, so it is what SigV4 verification must
40 /// canonicalize over. [`as_request_info`](Self::as_request_info) wires it
41 /// into [`RequestInfo`]'s signing path automatically. Integrators that
42 /// rewrite paths before dispatch (e.g. path-mapping) must still sign against
43 /// this encoded path — never the decoded [`path`](Self::path).
44 pub signing_path: String,
45 /// The raw query string, if present.
46 pub query: Option<String>,
47 /// The HTTP request headers.
48 pub headers: HeaderMap,
49 /// The collected form-encoded body of an
50 /// `application/x-www-form-urlencoded` `POST`, populated by
51 /// [`absorb_form_body`](Self::absorb_form_body). AWS SDKs send
52 /// query-protocol operations (STS `AssumeRoleWithWebIdentity`) this way,
53 /// so without it the STS route handler never sees SDK requests.
54 pub form_body: Option<String>,
55}
56
57impl RequestParts {
58 /// Parse a `web_sys::Request` into owned request metadata and a
59 /// zero-copy [`JsBody`].
60 ///
61 /// Extracts the body stream **before** reading headers, so the
62 /// `ReadableStream` is never locked.
63 pub fn from_web_sys(req: &web_sys::Request) -> Result<(Self, JsBody), String> {
64 let body = JsBody::new(req.body());
65
66 let method: Method = req
67 .method()
68 .parse()
69 .map_err(|e| format!("invalid method: {e}"))?;
70
71 let uri: Uri = req.url().parse().map_err(|e| format!("invalid URL: {e}"))?;
72
73 // `uri.path()` is the raw, percent-encoded path. Keep it verbatim for
74 // SigV4 signing (the client signs the encoded form), and separately
75 // decode it for operation parsing and bucket/key routing.
76 let signing_path = uri.path().to_string();
77 let path = percent_encoding::percent_decode_str(uri.path())
78 .decode_utf8_lossy()
79 .to_string();
80 let query = uri.query().map(|q| q.to_string());
81 let headers = headermap_from_js(&req.headers());
82
83 Ok((
84 Self {
85 method,
86 path,
87 signing_path,
88 query,
89 headers,
90 form_body: None,
91 },
92 body,
93 ))
94 }
95
96 /// Collect a form-encoded `POST` body into [`form_body`](Self::form_body),
97 /// returning an equivalent body to pass on to the gateway.
98 ///
99 /// A no-op passthrough for every other request shape, so integrators can
100 /// call it unconditionally between [`from_web_sys`](Self::from_web_sys)
101 /// and dispatch:
102 ///
103 /// ```rust,ignore
104 /// let (mut parts, mut body) = RequestParts::from_web_sys(&req)?;
105 /// body = parts.absorb_form_body(body).await?;
106 /// ```
107 ///
108 /// `Content-Type` is client-controlled, so a form-labeled `POST` is not
109 /// necessarily STS — it could be a mislabeled S3 write
110 /// (`CompleteMultipartUpload`, `DeleteObjects`). The returned body is
111 /// therefore rebuilt from the collected bytes, so a request that falls
112 /// through to the S3 pipeline sees its payload unchanged.
113 ///
114 /// Collection is gated on
115 /// [`RequestInfo::should_collect_form_body`](multistore::route_handler::RequestInfo::should_collect_form_body):
116 /// a form `POST` with a missing or oversized declared `Content-Length` is
117 /// passed through untouched rather than buffered into WASM memory. The
118 /// declared length bounds the actual bytes read because Cloudflare's edge
119 /// terminates HTTP — the request stream is derived from message framing,
120 /// which cannot deliver more bytes than the declared `Content-Length`.
121 pub async fn absorb_form_body(&mut self, body: JsBody) -> Result<JsBody, String> {
122 if !self.as_request_info().should_collect_form_body() {
123 return Ok(body);
124 }
125 let bytes = crate::body::collect_js_body(body).await?;
126 self.form_body = Some(String::from_utf8_lossy(&bytes).into_owned());
127 JsBody::from_bytes(&bytes)
128 }
129
130 /// Borrow this struct as a [`RequestInfo`] for gateway dispatch.
131 ///
132 /// Sets the signing path to the raw, percent-encoded
133 /// [`signing_path`](Self::signing_path) so SigV4 verification canonicalizes
134 /// over the path the client actually signed. Without this, a key containing
135 /// a character the client escapes — e.g. a space → `%20` — would be verified
136 /// against the decoded path and fail with `SignatureDoesNotMatch`.
137 pub fn as_request_info(&self) -> RequestInfo<'_> {
138 RequestInfo::new(
139 &self.method,
140 &self.path,
141 self.query.as_deref(),
142 &self.headers,
143 None,
144 )
145 .with_signing_path(&self.signing_path)
146 .with_form_body(self.form_body.as_deref())
147 }
148}