cheatah
Source

stdlib/requests/requests.hpp

1// Generated by purrc — do not edit.
2// cheatah-deps: hashlib parsers socket string tls
3#pragma once
4#include "cheatah.hpp"
5#include "hashlib.hpp"
6#include "parsers.hpp"
7#include "socket.hpp"
8#include "string.hpp"
9#include "tls.hpp"
11/**
12 * @file requests.hpp
13 *
14 * Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).
15 * Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.
16 * requests — HTTP for cheatah, in the spirit of Python's requests. THE FIRST STANDARD-LIBRARY
17 * MODULE WRITTEN IN PURE CHEATAH (.purr): all protocol logic below is cheatah source, compiled
18 * by purrc into an importable module. It rides on the C++-authored stdlib underneath —
19 * `socket` for TCP, `tls` for HTTPS, and `parsers` for URL/JSON parsing.
20 *
21 * v1.2 speaks HTTP/1.1 over plain TCP, one connection per request (`Connection: close`), and
22 * covers the everyday Python-requests surface:
23 * let r = requests.get("http://host:port/path")
24 * let o = Options({.timeout_ms = 5000})
25 * o.params["symbol"] = "SPX"
26 * let q = requests.get(url, o)
27 * if q.ok() { io.print(q.status_code, q.text()) }
28 * let body = requests.Options({.json_body = requests.to_json({"side": "buy"})})
29 * let p = requests.post("https://host/order", body)
30 * Verbs: get/post/put/patch/delete/head/options (and the generic request()). Request bodies:
31 * raw `body`, form `data` (application/x-www-form-urlencoded), or `json_body` (application/json).
32 * Auth: HTTP Basic via `auth_user`/`auth_pass`; Bearer/API-key by setting an `Authorization`
33 * header yourself. Also: query params (percent-encoded), custom headers, Content-Length /
34 * chunked / close-delimited body framing, redirect following with `history` and
35 * `no_redirect`, per-request timeouts, case-insensitive response headers, `Set-Cookie`
36 * capture, `raise_for_status()`, and `https://` through the from-scratch cheatah `tls` module
37 * (a TLS 1.3 client over the cheatah crypto modules — x25519, aead, hashlib HKDF; no OpenSSL;
38 * it REFUSES servers it cannot authenticate).
39 */
40namespace cheatah::requests {
42namespace builtins = ::cheatah::builtins;
43namespace hashlib = ::cheatah::hashlib;
44namespace parsers = ::cheatah::parsers;
45namespace socket = ::cheatah::socket;
46namespace string = ::cheatah::string;
47namespace tls = ::cheatah::tls;
49/**
50 * Per-request options.
51 *
52 * Unset fields take the documented defaults at request time: `timeout_ms <= 0` -> 30000,
53 * `max_redirects <= 0` -> 5. `params` are appended to the request-target percent-encoded;
54 * `headers` are sent verbatim (a User-Agent is added unless one is given). A body is taken from
55 * the first set of `json_body` -> `data` -> `body`; `Content-Type`/`Content-Length` are added
56 * automatically unless already present in `headers`. `auth_user`/`auth_pass` add HTTP Basic.
57 * Redirects are followed by default; set `no_redirect = true` to stop at the first 3xx.
58 * @systest RequestsSys.QueryParams
59 * @systest RequestsSys.CustomHeaders
60 */
61struct Options {
62 /**
63 * Query parameters appended to the request-target, percent-encoded (name -> value).
64 */
65 std::unordered_map<std::string, std::string> params;
66 /**
67 * Extra request headers sent verbatim (a default User-Agent is added unless one is given).
68 */
69 std::unordered_map<std::string, std::string> headers;
70 /**
71 * Per-request timeout in milliseconds; <= 0 uses the 30000 ms default.
72 */
73 long long timeout_ms{};
74 /**
75 * Maximum number of 3xx redirects to follow; <= 0 uses the default of 5.
76 */
77 long long max_redirects{};
78 /**
79 * Opt OUT of following 3xx redirects (cheatah's spelling of Python's `allow_redirects=False`).
80 * Redirects are followed BY DEFAULT (the zero value is "follow"); set true to stop at the 3xx.
81 */
82 bool no_redirect{};
83 /**
84 * A raw request body sent verbatim (lowest precedence; used when json_body/data are empty).
85 */
86 std::string body;
87 /**
88 * Form fields serialized as application/x-www-form-urlencoded (percent-encoded).
89 */
90 std::unordered_map<std::string, std::string> data;
91 /**
92 * A pre-serialized JSON string sent as application/json (highest body precedence).
93 */
94 std::string json_body;
95 /**
96 * HTTP Basic auth username; when non-empty, an `Authorization: Basic …` header is added.
97 */
98 std::string auth_user;
99 /**
100 * HTTP Basic auth password (paired with auth_user).
101 */
102 std::string auth_pass;
103 /**
104 * Maximum response body to accept, in bytes; <= 0 uses a 100 MiB default. A server that
105 * streams more (or declares a larger Content-Length) fails with an error instead of letting
106 * the client exhaust memory — a hard cap against a malicious/compromised peer.
107 */
108 long long max_bytes{};
109 /**
110 * For https: skip TLS certificate validation (chain/hostname/expiry). Default false = verify,
111 * so an active man-in-the-middle is refused. Set true ONLY for a pinned/controlled peer.
112 */
113 bool insecure{};
114 /**
115 * For https: a PEM CA bundle to trust instead of the system store (empty = system default).
116 */
117 std::string ca_file;
118};
120/**
121 * The outcome of one request.
122 *
123 * `error` distinguishes transport failures (DNS, refused, timeout, malformed URL/response)
124 * from HTTP-level failures: a 404 is a COMPLETED exchange — ok() is false but error stays
125 * "" and status_code/headers/body are real. Header names are stored LOWERCASED, so header()
126 * lookup is case-insensitive (RFC 9110). `cookies` holds `Set-Cookie` name=value pairs;
127 * `history` holds the intermediate responses when redirects were followed.
128 * @systest RequestsSys.NotFound
129 * @systest RequestsSys.ErrorPaths
130 */
131struct Response {
132 /**
133 * HTTP status code (e.g. 200, 404); 0 when the request never completed (see `error`).
134 */
135 long long status_code{};
136 /**
137 * HTTP reason phrase from the status line (e.g. "OK", "Not Found"); "" when absent.
138 */
139 std::string reason;
140 /**
141 * Response headers, with LOWERCASED names for case-insensitive lookup (use `header()`).
142 */
143 std::unordered_map<std::string, std::string> headers;
144 /**
145 * The response body bytes (decoded from chunked/Content-Length framing).
146 */
147 std::string body;
148 /**
149 * The final URL the response came from (after following any redirects).
150 */
151 std::string url;
152 /**
153 * Transport-level error message (DNS/connect/timeout/TLS/malformed); "" on a completed
154 * exchange, including a non-2xx HTTP status.
155 */
156 std::string error;
157 /**
158 * Cookies parsed from `Set-Cookie` response headers (name -> value; attributes dropped).
159 */
160 std::unordered_map<std::string, std::string> cookies;
161 /**
162 * The chain of intermediate responses when redirects were followed (oldest first); empty
163 * for a direct response.
164 */
165 std::vector<Response> history;
166 /**
167 * Whether the exchange completed with a 2xx status.
168 *
169 * @return true iff `error` is empty and `status_code` is in [200, 300).
170 * @complexity O(1).
171 * @alloc none.
172 * @systest RequestsSys.BasicGet
173 */
174 auto ok() const {
175 return ((((*this).error == std::string("")) && ((*this).status_code >= 200LL)) && ((*this).status_code < 300LL));
176 }
177 /**
178 * Case-insensitive response-header lookup.
179 *
180 * @param name header name, any capitalization (`"Content-Type"` == `"content-type"`).
181 * @return the header's value, or "" when the response did not carry it.
182 * @complexity O(|name|) to lowercase the key + O(1) average for the hash lookup.
183 * @alloc allocates the lowercased key.
184 * @systest RequestsSys.HeaderLookup
185 */
186 auto header(builtins::Value auto&& name) const {
187 auto key = string::lower(name);
188 if (builtins::contains((*this).headers, key)) {
189 return builtins::index((*this).headers, key);
190 }
191 return std::string("");
192 }
193 /**
194 * The response body as text (cheatah strings are byte-based, so text == content == body).
195 *
196 * @return the response body.
197 * @complexity O(1).
198 * @alloc none.
199 * @systest RequestsSys.BasicGet
200 */
201 auto text() const {
202 return (*this).body;
203 }
204 /**
205 * The response body as raw bytes (identical to `text()`/`body`; cheatah `str` is byte-safe).
206 *
207 * @return the response body.
208 * @complexity O(1).
209 * @alloc none.
210 * @systest RequestsSys.BasicGet
211 */
212 auto content() const {
213 return (*this).body;
214 }
215 /**
216 * Parse the JSON body into a caller-defined struct via the accelerated typed reader.
217 *
218 * This is the schema-typed `parsers.json.read` path: the target struct's schema is
219 * synthesized by purrc, so parsing goes straight into fields with no dynamic DOM. (A
220 * dynamic, struct-free `json()` for ad-hoc navigation is planned separately.)
221 * @param out a struct value to fill from the JSON body.
222 * @return true iff the body was valid JSON matching `out`'s schema.
223 * @complexity O(n) over the body length.
224 * @alloc fills `out`.
225 * @systest RequestsSys.JsonIntegration
226 */
227 auto json(builtins::Value auto&& out) const {
228 return parsers::json::read((*this).body, out);
229 }
230 /**
231 * Raise on a 4xx/5xx status (Python's `raise_for_status`); a no-op otherwise.
232 *
233 * @return nothing — raises (status + reason + url) on a 4xx/5xx, otherwise returns normally.
234 * @complexity O(1).
235 * @alloc allocates the message only when raising.
236 * @systest RequestsSys.RaiseForStatus
237 */
238 auto raise_for_status() const {
239 if (((*this).status_code >= 400LL) && ((*this).status_code < 600LL)) {
240 throw ::cheatah::builtins::Error(((((builtins::str((*this).status_code) + std::string(" ")) + builtins::str((*this).reason)) + std::string(" for url: ")) + builtins::str((*this).url)));
241 }
242 }
243 /**
244 * Whether the status is a 3xx redirect.
245 *
246 * @return true iff `status_code` is in [300, 400).
247 * @complexity O(1).
248 * @alloc none.
249 * @systest RequestsSys.AllowRedirectsFalse
250 */
251 auto is_redirect() const {
252 return (((*this).status_code >= 300LL) && ((*this).status_code < 400LL));
253 }
254 /**
255 * Whether the status is a permanent redirect (301 or 308).
256 *
257 * @return true iff `status_code` is 301 or 308.
258 * @complexity O(1).
259 * @alloc none.
260 * @systest RequestsSys.Redirect
261 */
262 auto is_permanent_redirect() const {
263 return (((*this).status_code == 301LL) || ((*this).status_code == 308LL));
264 }
265};
267} // namespace cheatah::requests (paused for JSON schema synthesis)
268namespace cheatah::parsers::json {
269/** JSON schema for `Options`, synthesized by purrc from the struct's fields — powers `parsers.json.read` into this type. */
270template <> inline constexpr auto schema<::cheatah::requests::Options> = object(field("params", &::cheatah::requests::Options::params), field("headers", &::cheatah::requests::Options::headers), field("timeout_ms", &::cheatah::requests::Options::timeout_ms), field("max_redirects", &::cheatah::requests::Options::max_redirects), field("no_redirect", &::cheatah::requests::Options::no_redirect), field("body", &::cheatah::requests::Options::body), field("data", &::cheatah::requests::Options::data), field("json_body", &::cheatah::requests::Options::json_body), field("auth_user", &::cheatah::requests::Options::auth_user), field("auth_pass", &::cheatah::requests::Options::auth_pass), field("max_bytes", &::cheatah::requests::Options::max_bytes), field("insecure", &::cheatah::requests::Options::insecure), field("ca_file", &::cheatah::requests::Options::ca_file));
271/** JSON schema for `Response`, synthesized by purrc from the struct's fields — powers `parsers.json.read` into this type. */
272template <> inline constexpr auto schema<::cheatah::requests::Response> = object(field("status_code", &::cheatah::requests::Response::status_code), field("reason", &::cheatah::requests::Response::reason), field("headers", &::cheatah::requests::Response::headers), field("body", &::cheatah::requests::Response::body), field("url", &::cheatah::requests::Response::url), field("error", &::cheatah::requests::Response::error), field("cookies", &::cheatah::requests::Response::cookies), field("history", &::cheatah::requests::Response::history));
273} // namespace cheatah::parsers::json
274namespace cheatah::requests {
276/**
277 * Does `text` contain a byte that would break out of the line it is written on?
278 *
279 * The request is built by concatenating a request-target and header values into a CRLF-framed
280 * message, so a CR or LF reaching either one lets a caller inject headers or split the request
281 * entirely. That matters most when the value is not the caller's own: a URL taken from a fetched
282 * document or a `Location` header is attacker-controlled data, and nothing else on the path
283 * re-checks it. Refusing here means no consumer of this module can be made to emit a forged
284 * request, whatever it was handed.
285 *
286 * @param text a request-target or header value about to be written to the wire.
287 * @return true when `text` carries CR or LF and must not be sent.
288 * @complexity O(n) over the input length.
289 * @alloc none.
290 * @test CheatahRequests.CrlfInjectionRefused
291 */
292inline auto has_control_bytes(builtins::Value auto&& text) {
293 return ((string::find(text, std::string("\r")) >= 0LL) || (string::find(text, std::string("\n")) >= 0LL));
297/**
298 * Percent-encode `text` for a query string or form body.
299 *
300 * RFC 3986 unreserved characters pass through; everything else (including space)
301 * becomes %XX with uppercase hex digits.
302 * @param text the raw name or value.
303 * @return the percent-encoded form, safe to place in a request-target or form body.
304 * @complexity O(n) over the input length.
305 * @alloc allocates the result.
306 * @systest RequestsSys.QueryParams
307 */
308inline auto percent_encode(builtins::Value auto&& text) {
309 auto unreserved = std::string("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~");
310 auto hexdigits = std::string("0123456789ABCDEF");
311 auto out = std::string("");
312 for (auto& ch : text) {
313 if (string::contains(unreserved, ch)) {
314 out += ch;
315 } else {
316 auto v = builtins::ord(ch);
317 ((out += "%") += builtins::index(hexdigits, builtins::floordiv(v, 16LL))) += builtins::index(hexdigits, builtins::mod(v, 16LL));
318 }
319 }
320 return out;
324/**
325 * Escape a string for embedding as a JSON string value.
326 *
327 * EVERY control byte is escaped, not just the five with short forms. RFC 8259 §7 forbids a raw
328 * byte below 0x20 inside a string, so a value carrying one produced output that is not JSON, and a
329 * strict parser rejects the whole document rather than the one field. This escaper was correct for
330 * text somebody typed and wrong for anything that had been anywhere else — an id, a token, a header
331 * value read off a socket.
332 *
333 * @param text the raw string.
334 * @return the escaped string.
335 * @complexity O(n) over the input length.
336 * @alloc allocates the result.
337 * @systest RequestsSys.PostJson
338 */
339inline auto json_escape(builtins::Value auto&& text) {
340 auto hexdigits = std::string("0123456789abcdef");
341 auto out = std::string("");
342 for (auto& ch : text) {
343 auto code = builtins::ord(ch);
344 {
345 auto _purr_match_0 = code;
346 switch (_purr_match_0) {
347 case 34LL: {
348 out += "\\\"";
349 break;
350 }
351 case 92LL: {
352 out += "\\\\";
353 break;
354 }
355 case 10LL: {
356 out += "\\n";
357 break;
358 }
359 case 13LL: {
360 out += "\\r";
361 break;
362 }
363 case 9LL: {
364 out += "\\t";
365 break;
366 }
367 case 8LL: {
368 out += "\\b";
369 break;
370 }
371 case 12LL: {
372 out += "\\f";
373 break;
374 }
375 default: {
376 if (code < 32LL) {
377 ((out += "\\u00") += builtins::index(hexdigits, builtins::floordiv(code, 16LL))) += builtins::index(hexdigits, builtins::mod(code, 16LL));
378 } else {
379 out += ch;
380 }
381 break;
382 }
383 }
384 }
385 }
386 return out;
390/**
391 * Serialize a flat string->string dict as a JSON object — the common `json=` case.
392 *
393 * For nested or non-string JSON, build the string yourself and pass it as `json_body`.
394 * @param fields the name -> value pairs.
395 * @return a JSON object string (`{"k":"v",…}`).
396 * @complexity O(total characters).
397 * @alloc allocates the result.
398 * @systest RequestsSys.PostJson
399 */
400inline auto to_json(builtins::Value auto&& fields) {
401 auto out = std::string("{");
402 auto sep = std::string("");
403 for (auto& kv : fields) {
404 (((((out += sep) += "\"") += json_escape(kv.first)) += "\":\"") += json_escape(kv.second)) += "\"";
405 sep = std::string(",");
406 }
407 return (builtins::str(out) + std::string("}"));
411/**
412 * Case-insensitive presence test for a header name in a headers dict.
413 *
414 * @param headers the request headers (name -> value).
415 * @param name the header name to look for, any capitalization.
416 * @return true iff a header with that name (case-insensitive) is present.
417 * @complexity O(total header-name bytes) — every name is lowercased for the compare.
418 * @alloc allocates lowercased keys.
419 * @systest RequestsSys.CustomHeaders
420 */
421inline auto has_header(builtins::Value auto&& headers, builtins::Value auto&& name) {
422 auto target = string::lower(name);
423 auto found = false;
424 for (auto& kv : headers) {
425 if (string::lower(kv.first) == target) {
426 found = true;
427 break;
428 }
429 }
430 return found;
434/**
435 * Parse one hex chunk-size token (e.g. "1aF").
436 *
437 * @param text the hex token, both digit cases accepted, no prefix.
438 * @return the decoded size, or -1 on a malformed digit. An empty token decodes to 0,
439 * which the chunked framing treats as the final chunk.
440 * @complexity O(n) over the token length.
441 * @alloc none.
442 * @systest RequestsSys.Chunked
443 */
444inline auto parse_hex(builtins::Value auto&& text) {
445 auto n = 0LL;
446 for (auto& ch : text) {
447 if (n > 1099511627776LL) {
448 return (-1LL);
449 }
450 auto v = builtins::ord(ch);
451 if ((v >= 48LL) && (v <= 57LL)) {
452 n = ((n * 16LL) + (v - 48LL));
453 } else {
454 if ((v >= 97LL) && (v <= 102LL)) {
455 n = ((n * 16LL) + (v - 87LL));
456 } else {
457 if ((v >= 65LL) && (v <= 70LL)) {
458 n = ((n * 16LL) + (v - 55LL));
459 } else {
460 return (-1LL);
461 }
462 }
463 }
464 }
465 return n;
469/**
470 * Parse an unsigned decimal integer, safely — returns -1 on any non-digit, empty input, or a
471 * value beyond 1 TiB (overflow guard). Used for the status code and Content-Length so a
472 * malformed header sets `error` instead of throwing out of the "never raises" request path.
473 *
474 * Callers always pass a non-empty slice (a 3-char status field, or a non-empty Content-Length),
475 * so an empty string is not handled specially.
476 * @param text the decimal digits (e.g. a Content-Length value); must be non-empty.
477 * @return the parsed value, or -1 when @p text is not a plain in-range unsigned integer.
478 * @complexity O(n) over the digit count.
479 * @alloc none.
480 * @test CheatahRequests.MalformedContentLengthIsError
481 * @test CheatahRequests.MalformedStatusCodeIsError
482 */
483inline auto parse_uint(builtins::Value auto&& text) {
484 auto n = 0LL;
485 for (auto& ch : text) {
486 auto v = builtins::ord(ch);
487 if ((v < 48LL) || (v > 57LL)) {
488 return (-1LL);
489 }
490 n = ((n * 10LL) + (v - 48LL));
491 if (n > 1099511627776LL) {
492 return (-1LL);
493 }
494 }
495 return n;
499/**
500 * Decode an HTTP/1.1 chunked body.
501 *
502 * Framing: hex-size line (chunk extensions after `;` ignored), chunk bytes, CRLF,
503 * repeated until the 0 chunk. Trailers after the 0 chunk are simply unread.
504 * @param raw everything after the response head (the body bytes as received).
505 * @param r the Response under construction; `r.error` is set on malformed framing.
506 * @return the decoded body, or "" when framing was malformed (see `r.error`).
507 * @complexity O(n) over the body length.
508 * @alloc allocates the decoded body.
509 * @systest RequestsSys.Chunked
510 */
511inline auto dechunk(builtins::Value auto&& raw, builtins::Value auto&& r) {
512 auto body = std::string("");
513 auto pos = 0LL;
514 while (true) {
515 auto crlf = string::find(raw, std::string("\r\n"), pos);
516 if (crlf < 0LL) {
517 r.error = std::string("connection closed inside chunked body");
518 return std::string("");
519 }
520 auto line_end = (crlf - pos);
521 auto size_token = builtins::slice(raw, pos, (pos + line_end));
522 auto semi = string::find(size_token, std::string(";"));
523 if (semi >= 0LL) {
524 size_token = builtins::slice(size_token, 0LL, semi);
525 }
526 auto size = parse_hex(string::strip(size_token));
527 if (size < 0LL) {
528 r.error = std::string("malformed chunk size");
529 return std::string("");
530 }
531 (pos += line_end) += 2LL;
532 if (size == 0LL) {
533 return body;
534 }
535 if (builtins::len(raw) < ((pos + size) + 2LL)) {
536 r.error = std::string("connection closed inside chunked body");
537 return std::string("");
538 }
539 body += builtins::slice(raw, pos, (pos + size));
540 (pos += size) += 2LL;
541 }
545/**
546 * Read the whole response from a connected socket (or TLS session for https).
547 *
548 * Reads every byte until the peer closes — or the socket timeout set by request_once
549 * fires, which recv reports as "" (indistinguishable from close by design: we always
550 * request `Connection: close`, so EOF IS the end of the response). When @p conn is open
551 * the bytes are read (and decrypted) through its TLS session, mirroring the send path;
552 * a closed @p conn reads the plain socket.
553 * Stops once more than @p limit bytes have arrived so a malicious/compromised peer cannot
554 * stream an unbounded body and exhaust memory; the caller treats an over-limit read as an error.
555 * @param fd a connected socket descriptor from socket.tcp_connect.
556 * @param conn the owning tls.Conn for https (open), or a closed Conn for plain http.
557 * @param limit the maximum number of body bytes to accept before bailing out.
558 * @return the bytes received, bounded to at most one 64 KiB chunk beyond @p limit.
559 * @complexity O(n) over the response size (bounded by @p limit).
560 * @alloc allocates the received buffer (bounded by @p limit).
561 * @systest RequestsSys.BasicGet
562 */
563inline auto read_all(builtins::Value auto&& fd, builtins::Value auto&& conn, builtins::Value auto&& limit) {
564 auto raw = std::string("");
565 while (true) {
566 auto chunk = std::string("");
567 if (conn.is_open()) {
568 chunk = conn.recv(65536LL);
569 } else {
570 chunk = socket::recv(fd, 65536LL);
571 }
572 if (builtins::len(chunk) == 0LL) {
573 break;
574 }
575 raw += chunk;
576 if (builtins::len(raw) > limit) {
577 break;
578 }
579 }
580 return raw;
584/**
585 * Parse a raw HTTP/1.1 response (status line + headers + body) into `r`.
586 *
587 * Headers land in `r.headers` with LOWERCASED names and stripped values; the reason phrase
588 * fills `r.reason` and any `Set-Cookie` name=value pairs fill `r.cookies`. Body framing
589 * precedence: chunked when declared, else Content-Length, else everything to EOF (we always
590 * send `Connection: close`). A HEAD request carries no body regardless of the framing headers.
591 * Malformed input sets `r.error` and returns early.
592 * The status code and Content-Length are parsed with `parse_uint`, so a malformed value (e.g.
593 * `Content-Length: abc`, a huge overflowing number, or a negative length) sets `error` instead
594 * of throwing out of the request path; a Content-Length beyond @p limit is rejected outright.
595 * @param raw the complete response bytes as received.
596 * @param r the Response under construction (status_code/reason/headers/cookies/body/error).
597 * @param method the request method (so HEAD skips body framing).
598 * @param limit the maximum acceptable body size in bytes.
599 * @return `r`, completed or carrying `error`.
600 * @complexity O(n) over the response size.
601 * @alloc allocates the parsed headers and body.
602 * @systest RequestsSys.EofFraming
603 * @systest RequestsSys.HeaderLookup
604 */
605inline auto parse_response(builtins::Value auto&& raw, builtins::Value auto&& r, builtins::Value auto&& method, builtins::Value auto&& limit) {
606 auto head_end = string::find(raw, std::string("\r\n\r\n"));
607 if (head_end < 0LL) {
608 r.error = std::string("connection closed before a complete response head");
609 return r;
610 }
611 if (string::find(raw, std::string("HTTP/")) != 0LL) {
612 r.error = std::string("malformed response head");
613 return r;
614 }
615 auto head_block = builtins::slice(raw, 0LL, head_end);
616 auto line_end = string::find(head_block, std::string("\r\n"));
617 if (line_end < 0LL) {
618 line_end = builtins::len(head_block);
619 }
620 auto space = string::find(head_block, std::string(" "));
621 if ((space < 0LL) || ((space + 4LL) > line_end)) {
622 r.error = std::string("malformed status line");
623 return r;
624 }
625 auto code = parse_uint(builtins::slice(head_block, (space + 1LL), (space + 4LL)));
626 if (code < 0LL) {
627 r.error = std::string("malformed status code");
628 return r;
629 }
630 r.status_code = code;
631 if ((space + 5LL) <= line_end) {
632 r.reason = string::strip(builtins::slice(head_block, (space + 5LL), line_end));
633 }
634 auto hend = builtins::len(head_block);
635 auto hpos = (line_end + 2LL);
636 while (hpos < hend) {
637 auto eol = string::find(head_block, std::string("\r\n"), hpos);
638 if (eol < 0LL) {
639 eol = hend;
640 }
641 auto line = builtins::slice(head_block, hpos, eol);
642 auto colon = string::find(line, std::string(":"));
643 if (colon > 0LL) {
644 auto name = string::lower(string::strip(builtins::slice(line, 0LL, colon)));
645 auto value = string::strip(builtins::slice(line, (colon + 1LL), builtins::slice_end));
646 r.headers[name] = value;
647 if (name == std::string("set-cookie")) {
648 auto semi = string::find(value, std::string(";"));
649 auto pair = value;
650 if (semi >= 0LL) {
651 pair = builtins::slice(value, 0LL, semi);
652 }
653 auto eq = string::find(pair, std::string("="));
654 if (eq > 0LL) {
655 r.cookies[string::strip(builtins::slice(pair, 0LL, eq))] = string::strip(builtins::slice(pair, (eq + 1LL), builtins::slice_end));
656 }
657 }
658 }
659 if (eol == hend) {
660 break;
661 }
662 hpos = (eol + 2LL);
663 }
664 if (method == std::string("HEAD")) {
665 r.body = std::string("");
666 return r;
667 }
668 auto body = builtins::slice(raw, (head_end + 4LL), builtins::slice_end);
669 if (r.header(std::string("transfer-encoding")) == std::string("chunked")) {
670 r.body = dechunk(body, r);
671 return r;
672 }
673 auto cl = r.header(std::string("content-length"));
674 if (cl != std::string("")) {
675 auto n = parse_uint(cl);
676 if (n < 0LL) {
677 r.error = std::string("invalid Content-Length");
678 return r;
679 }
680 if (n > limit) {
681 r.error = std::string("Content-Length exceeds max_bytes");
682 return r;
683 }
684 if (builtins::len(body) < n) {
685 r.error = std::string("connection closed before the complete body arrived");
686 return r;
687 }
688 r.body = builtins::slice(body, 0LL, n);
689 } else {
690 r.body = body;
691 }
692 return r;
696/**
697 * One request exchange against an already-parsed URL (no redirect handling).
698 *
699 * Connects, applies the timeout, appends `o.params` percent-encoded to the request-target,
700 * builds the body (json_body -> data -> body; GET/HEAD send none), sends the request (custom
701 * headers, auto Content-Type/Content-Length/Authorization, default User-Agent, `Connection:
702 * close`), then reads and parses the full response. Transport failures come back in `r.error`.
703 * @param method the HTTP method ("GET", "POST", …).
704 * @param u the parsed URL (scheme/host/port/target) from parsers.url.
705 * @param o per-request options (timeout, params, headers, body, auth).
706 * @param r the Response under construction.
707 * @return the completed Response (a non-2xx status is a completed exchange, not an error).
708 * @complexity O(request + response bytes) (+ one TLS handshake for https).
709 * @alloc allocates the request and response buffers (+ a `tls` session for https).
710 * @concurrency blocking — connect/handshake/read are all bounded by the socket timeout
711 * (`o.timeout_ms`, default 30 s).
712 * @systest RequestsSys.PostJson
713 */
714inline auto request_once(builtins::Value auto&& method, builtins::Value auto&& u, builtins::Value auto&& o, builtins::Value auto&& r) {
715 auto fd = socket::tcp_connect(u.host, u.port);
716 if (fd < 0LL) {
717 r.error = (((((std::string("connect to ") + builtins::str(u.host)) + std::string(":")) + builtins::str(u.port)) + std::string(" failed: ")) + builtins::str(socket::last_error()));
718 return r;
719 }
720 auto timeout = o.timeout_ms;
721 if (timeout <= 0LL) {
722 timeout = 30000LL;
723 }
724 socket::set_timeout(fd, timeout);
725 auto conn = tls::Conn();
726 if (u.scheme == std::string("https")) {
727 conn = tls::open(fd, u.host, o.insecure, o.ca_file);
728 if (!conn.is_open()) {
729 socket::close(fd);
730 r.error = (std::string("tls: ") + builtins::str(tls::last_error()));
731 return r;
732 }
733 }
734 auto body = std::string("");
735 auto ctype = std::string("");
736 if ((method != std::string("GET")) && (method != std::string("HEAD"))) {
737 if (o.json_body != std::string("")) {
738 body = o.json_body;
739 ctype = std::string("application/json");
740 } else {
741 if (builtins::len(o.data) > 0LL) {
742 auto sep = std::string("");
743 for (auto& kv : o.data) {
744 (((body += sep) += percent_encode(kv.first)) += "=") += percent_encode(kv.second);
745 sep = std::string("&");
746 }
747 ctype = std::string("application/x-www-form-urlencoded");
748 } else {
749 if (o.body != std::string("")) {
750 body = o.body;
751 }
752 }
753 }
754 }
755 auto target = u.target;
756 auto sep = std::string("?");
757 if (string::find(target, std::string("?")) >= 0LL) {
758 sep = std::string("&");
759 }
760 for (auto& kv : o.params) {
761 (((target += sep) += percent_encode(kv.first)) += "=") += percent_encode(kv.second);
762 sep = std::string("&");
763 }
764 if (has_control_bytes(target) || has_control_bytes(u.host)) {
765 conn.close();
766 socket::close(fd);
767 r.error = std::string("refused: control bytes in the request target");
768 return r;
769 }
770 auto req = (((((((builtins::str(method) + std::string(" ")) + builtins::str(target)) + std::string(" HTTP/1.1\r\nHost: ")) + builtins::str(u.host)) + std::string(":")) + builtins::str(u.port)) + std::string("\r\n"));
771 for (auto& kv : o.headers) {
772 if (has_control_bytes(kv.first) || has_control_bytes(kv.second)) {
773 conn.close();
774 socket::close(fd);
775 r.error = std::string("refused: control bytes in a request header");
776 return r;
777 }
778 (((req += kv.first) += ": ") += kv.second) += "\r\n";
779 }
780 if (!has_header(o.headers, std::string("User-Agent"))) {
781 req += "User-Agent: cheatah-requests/1.2\r\n";
782 }
783 if ((o.auth_user != std::string("")) && (!has_header(o.headers, std::string("Authorization")))) {
784 ((req += "Authorization: Basic ") += hashlib::base64_encode(((builtins::str(o.auth_user) + std::string(":")) + builtins::str(o.auth_pass)))) += "\r\n";
785 }
786 if ((ctype != std::string("")) && (!has_header(o.headers, std::string("Content-Type")))) {
787 ((req += "Content-Type: ") += ctype) += "\r\n";
788 }
789 if (!has_header(o.headers, std::string("Content-Length"))) {
790 if ((((builtins::len(body) > 0LL) || (method == std::string("POST"))) || (method == std::string("PUT"))) || (method == std::string("PATCH"))) {
791 ((req += "Content-Length: ") += builtins::str(builtins::len(body))) += "\r\n";
792 }
793 }
794 (req += "Connection: close\r\nAccept: */*\r\n\r\n") += body;
795 auto sent = 0LL;
796 if (conn.is_open()) {
797 sent = conn.send(req);
798 } else {
799 sent = socket::sendall(fd, req);
800 }
801 if (sent != 0LL) {
802 conn.close();
803 socket::close(fd);
804 r.error = std::string("send failed");
805 return r;
806 }
807 auto limit = o.max_bytes;
808 if (limit <= 0LL) {
809 limit = 104857600LL;
810 }
811 auto raw = read_all(fd, conn, limit);
812 conn.close();
813 socket::close(fd);
814 if (builtins::len(raw) > limit) {
815 r.error = ((std::string("response body exceeds max_bytes (") + builtins::str(limit)) + std::string(")"));
816 return r;
817 }
818 return parse_response(raw, r, method, limit);
822/**
823 * Strip credentials and cookies from `o` — used when a redirect crosses to a different host so
824 * secrets scoped to the original host are never sent to another (the classic cross-origin
825 * redirect credential leak). Clears Basic auth and drops any `Authorization`/`Cookie` header.
826 *
827 * @param o the (per-request, already-copied) options to sanitize in place.
828 * @return nothing — @p o is modified in place (auth cleared, sensitive headers dropped).
829 * @complexity O(k) over the number of headers.
830 * @alloc allocates the rebuilt header map.
831 * @test CheatahRequests.CrossHostRedirectStripsCredentials
832 * @test CheatahRequests.SameHostRedirectKeepsCredentials
833 */
834inline auto strip_sensitive(builtins::Value auto&& o) {
835 o.auth_user = std::string("");
836 o.auth_pass = std::string("");
837 std::unordered_map<std::string, std::string> clean;
838 for (auto& kv : o.headers) {
839 auto lname = string::lower(kv.first);
840 if ((lname != std::string("authorization")) && (lname != std::string("cookie"))) {
841 clean[kv.first] = kv.second;
842 }
843 }
844 o.headers = clean;
848/**
849 * Perform an HTTP request, following up to max_redirects 3xx hops unless no_redirect.
850 *
851 * On a redirect to a DIFFERENT host, Basic-auth credentials and any `Authorization`/`Cookie`
852 * header are stripped before the next hop, so secrets are never leaked to another origin.
853 * Never raises for network conditions: every failure comes back as a Response with `error`
854 * set (and status_code 0). Redirects (301/302/303/307/308) follow absolute and host-relative
855 * Location targets, recording each hop in the returned Response's `history`; 303 (and 301/302
856 * on a POST) switch the method to GET and drop the body, matching Python. Set
857 * `o.no_redirect = true` to return the 3xx response directly.
858 * @param method the HTTP method ("GET", "POST", …).
859 * @param url the absolute `http(s)://host[:port]/path[?query]` URL.
860 * @param o per-request options; defaults to a 30 s timeout and 5 redirect hops (redirects followed unless no_redirect).
861 * @return the final Response — check `ok()`, then `status_code`/`headers`/`body`.
862 * @complexity one full exchange (request_once) per hop, at most 1 + max_redirects hops.
863 * @alloc allocates each hop's request/response buffers, the recorded `history`, and a
864 * private copy of @p o (so redirect-time credential stripping never mutates the caller's).
865 * @concurrency blocking, with every hop's socket I/O bounded by `timeout_ms`; no shared
866 * state — concurrent requests from separate threads are independent.
867 * @systest RequestsSys.BasicGet
868 * @systest RequestsSys.Redirect
869 * @systest RequestsSys.RedirectLoop
870 */
871inline auto request(builtins::Value auto&& method, builtins::Value auto&& url, builtins::Value auto&& o) {
872 auto opts = o;
873 auto max_hops = opts.max_redirects;
874 if (max_hops <= 0LL) {
875 max_hops = 5LL;
876 }
877 auto current = url;
878 auto cur_method = method;
879 auto hop = 0LL;
880 auto origin_host = std::string("");
881 std::vector<Response> hist;
882 while (hop <= max_hops) {
883 auto r = Response{.url = static_cast<std::string>(current)};
884 auto parser = parsers::url::Parser();
885 auto u = parsers::url::Url();
886 if (!parser.parse(current, u)) {
887 r.error = (std::string("malformed URL: ") + builtins::str(current));
888 r.history = hist;
889 return r;
890 }
891 if (origin_host == std::string("")) {
892 origin_host = u.host;
893 } else {
894 if (u.host != origin_host) {
895 strip_sensitive(opts);
896 }
897 }
898 r = request_once(cur_method, u, opts, r);
899 auto redirect = ((r.error == std::string("")) && (((((r.status_code == 301LL) || (r.status_code == 302LL)) || (r.status_code == 303LL)) || (r.status_code == 307LL)) || (r.status_code == 308LL)));
900 if (opts.no_redirect || (!redirect)) {
901 r.history = hist;
902 return r;
903 }
904 auto loc = r.header(std::string("location"));
905 if (loc == std::string("")) {
906 r.error = ((std::string("redirect (") + builtins::str(r.status_code)) + std::string(") without a Location header"));
907 r.history = hist;
908 return r;
909 }
910 auto next = std::string("");
911 if (string::startswith(loc, std::string("http://")) || string::startswith(loc, std::string("https://"))) {
912 next = loc;
913 } else {
914 if (string::startswith(loc, std::string("/"))) {
915 next = (((((builtins::str(u.scheme) + std::string("://")) + builtins::str(u.host)) + std::string(":")) + builtins::str(u.port)) + builtins::str(loc));
916 } else {
917 r.error = (std::string("unsupported relative redirect Location: ") + builtins::str(loc));
918 r.history = hist;
919 return r;
920 }
921 }
922 builtins::append(hist, r);
923 {
924 auto _purr_match_1 = r.status_code;
925 switch (_purr_match_1) {
926 case 303LL: {
927 cur_method = std::string("GET");
928 break;
929 }
930 case 301LL: case 302LL: {
931 if (cur_method == std::string("POST")) {
932 cur_method = std::string("GET");
933 }
934 break;
935 }
936 default: {
937 break;
938 }
939 }
940 }
941 current = next;
942 hop += 1LL;
943 }
944 auto r = Response{.url = static_cast<std::string>(current)};
945 r.error = ((std::string("too many redirects (max_redirects = ") + builtins::str(max_hops)) + std::string(")"));
946 r.history = hist;
947 return r;
950/**
951 * Convenience overload of `request` with the trailing default argument(s) applied.
952 * @param method as documented on the primary overload.
953 * @param url as documented on the primary overload.
954 * @return as the primary overload.
955 */
956static auto request(builtins::Value auto&& method, builtins::Value auto&& url) { return request(method, url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
958/**
959 * HTTP GET. @param url the URL. @param o per-request options.
960 * @return the final Response. @complexity one request plus any redirects.
961 * @alloc request/response buffers. @systest RequestsSys.BasicGet
962 */
963inline auto get(builtins::Value auto&& url, builtins::Value auto&& o) {
964 return request(std::string("GET"), url, o);
967/**
968 * Convenience overload of `get` with the trailing default argument(s) applied.
969 * @param url as documented on the primary overload.
970 * @return as the primary overload.
971 */
972static auto get(builtins::Value auto&& url) { return get(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
974/**
975 * HTTP POST. @param url the URL. @param o per-request options (body via json_body/data/body).
976 * @return the final Response. @complexity one request plus any redirects.
977 * @alloc request/response buffers. @systest RequestsSys.PostJson
978 */
979inline auto post(builtins::Value auto&& url, builtins::Value auto&& o) {
980 return request(std::string("POST"), url, o);
983/**
984 * Convenience overload of `post` with the trailing default argument(s) applied.
985 * @param url as documented on the primary overload.
986 * @return as the primary overload.
987 */
988static auto post(builtins::Value auto&& url) { return post(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
990/**
991 * HTTP PUT.
992 *
993 * @param url the URL.
994 * @param o per-request options (body via json_body/data/body).
995 * @return the final Response.
996 * @complexity one request plus any redirects.
997 * @alloc request/response buffers.
998 * @test CheatahRequests.VerbMethods
999 */
1000inline auto put(builtins::Value auto&& url, builtins::Value auto&& o) {
1001 return request(std::string("PUT"), url, o);
1004/**
1005 * Convenience overload of `put` with the trailing default argument(s) applied.
1006 * @param url as documented on the primary overload.
1007 * @return as the primary overload.
1008 */
1009static auto put(builtins::Value auto&& url) { return put(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
1011/**
1012 * HTTP PATCH.
1014 * @param url the URL.
1015 * @param o per-request options (body via json_body/data/body).
1016 * @return the final Response.
1017 * @complexity one request plus any redirects.
1018 * @alloc request/response buffers.
1019 * @test CheatahRequests.VerbMethods
1020 */
1021inline auto patch(builtins::Value auto&& url, builtins::Value auto&& o) {
1022 return request(std::string("PATCH"), url, o);
1025/**
1026 * Convenience overload of `patch` with the trailing default argument(s) applied.
1027 * @param url as documented on the primary overload.
1028 * @return as the primary overload.
1029 */
1030static auto patch(builtins::Value auto&& url) { return patch(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
1032/**
1033 * HTTP DELETE. @param url the URL. @param o per-request options.
1034 * @return the final Response. @complexity one request plus any redirects.
1035 * @alloc request/response buffers. @systest RequestsSys.Delete
1036 */
1037inline auto delete_(builtins::Value auto&& url, builtins::Value auto&& o) {
1038 return request(std::string("DELETE"), url, o);
1041/**
1042 * Convenience overload of `delete` with the trailing default argument(s) applied.
1043 * @param url as documented on the primary overload.
1044 * @return as the primary overload.
1045 */
1046static auto delete_(builtins::Value auto&& url) { return delete_(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
1048/**
1049 * HTTP HEAD (headers only, no body). @param url the URL. @param o per-request options.
1050 * @return the final Response (empty body). @complexity one request plus any redirects.
1051 * @alloc request/response buffers. @systest RequestsSys.Head
1052 */
1053inline auto head(builtins::Value auto&& url, builtins::Value auto&& o) {
1054 return request(std::string("HEAD"), url, o);
1057/**
1058 * Convenience overload of `head` with the trailing default argument(s) applied.
1059 * @param url as documented on the primary overload.
1060 * @return as the primary overload.
1061 */
1062static auto head(builtins::Value auto&& url) { return head(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
1064/**
1065 * HTTP OPTIONS. @param url the URL. @param o per-request options.
1066 * @return the final Response. @complexity one request plus any redirects.
1067 * @alloc request/response buffers. @test CheatahRequests.VerbMethods
1068 */
1069inline auto options(builtins::Value auto&& url, builtins::Value auto&& o) {
1070 return request(std::string("OPTIONS"), url, o);
1073/**
1074 * Convenience overload of `options` with the trailing default argument(s) applied.
1075 * @param url as documented on the primary overload.
1076 * @return as the primary overload.
1077 */
1078static auto options(builtins::Value auto&& url) { return options(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }
1080/// ABI/identity marker for the `requests` cheatah module: returns the module name.
1081///
1082/// Auto-emitted by purrc's library emitter. It is the concrete symbol that
1083/// anchors the module's signed static archive in opaque (source-hidden) builds.
1084/// @return the module name (`"requests"`).
1085inline const char* module_abi() noexcept { return "requests"; }
1087} // namespace cheatah::requests