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 * @systest 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 __match_0 = code;
346 switch (__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 for (auto& kv : headers) {
424 if ((string::lower(kv.first) == target)) {
425 return true;
426 }
427 }
428 return false;
432/**
433 * Parse one hex chunk-size token (e.g. "1aF").
434 *
435 * @param text the hex token, both digit cases accepted, no prefix.
436 * @return the decoded size, or -1 on a malformed digit. An empty token decodes to 0,
437 * which the chunked framing treats as the final chunk.
438 * @complexity O(n) over the token length.
439 * @alloc none.
440 * @systest RequestsSys.Chunked
441 */
442inline auto parse_hex(builtins::Value auto&& text) {
443 auto n = 0LL;
444 for (auto& ch : text) {
445 if ((n > 1099511627776LL)) {
446 return (-1LL);
447 }
448 auto v = builtins::ord(ch);
449 if (((v >= 48LL) && (v <= 57LL))) {
450 n = ((n * 16LL) + (v - 48LL));
451 } else {
452 if (((v >= 97LL) && (v <= 102LL))) {
453 n = ((n * 16LL) + (v - 87LL));
454 } else {
455 if (((v >= 65LL) && (v <= 70LL))) {
456 n = ((n * 16LL) + (v - 55LL));
457 } else {
458 return (-1LL);
459 }
460 }
461 }
462 }
463 return n;
467/**
468 * Parse an unsigned decimal integer, safely — returns -1 on any non-digit, empty input, or a
469 * value beyond 1 TiB (overflow guard). Used for the status code and Content-Length so a
470 * malformed header sets `error` instead of throwing out of the "never raises" request path.
471 *
472 * Callers always pass a non-empty slice (a 3-char status field, or a non-empty Content-Length),
473 * so an empty string is not handled specially.
474 * @param text the decimal digits (e.g. a Content-Length value); must be non-empty.
475 * @return the parsed value, or -1 when @p text is not a plain in-range unsigned integer.
476 * @complexity O(n) over the digit count.
477 * @alloc none.
478 * @test CheatahRequests.MalformedContentLengthIsError
479 * @test CheatahRequests.MalformedStatusCodeIsError
480 */
481inline auto parse_uint(builtins::Value auto&& text) {
482 auto n = 0LL;
483 for (auto& ch : text) {
484 auto v = builtins::ord(ch);
485 if (((v < 48LL) || (v > 57LL))) {
486 return (-1LL);
487 }
488 n = ((n * 10LL) + (v - 48LL));
489 if ((n > 1099511627776LL)) {
490 return (-1LL);
491 }
492 }
493 return n;
497/**
498 * Decode an HTTP/1.1 chunked body.
499 *
500 * Framing: hex-size line (chunk extensions after `;` ignored), chunk bytes, CRLF,
501 * repeated until the 0 chunk. Trailers after the 0 chunk are simply unread.
502 * @param raw everything after the response head (the body bytes as received).
503 * @param r the Response under construction; `r.error` is set on malformed framing.
504 * @return the decoded body, or "" when framing was malformed (see `r.error`).
505 * @complexity O(n) over the body length.
506 * @alloc allocates the decoded body.
507 * @systest RequestsSys.Chunked
508 */
509inline auto dechunk(builtins::Value auto&& raw, builtins::Value auto&& r) {
510 auto body = std::string("");
511 auto pos = 0LL;
512 while (true) {
513 auto crlf = string::find(raw, std::string("\r\n"), pos);
514 if ((crlf < 0LL)) {
515 r.error = std::string("connection closed inside chunked body");
516 return std::string("");
517 }
518 auto line_end = (crlf - pos);
519 auto size_token = builtins::slice(raw, pos, (pos + line_end));
520 auto semi = string::find(size_token, std::string(";"));
521 if ((semi >= 0LL)) {
522 size_token = builtins::slice(size_token, 0LL, semi);
523 }
524 auto size = parse_hex(string::strip(size_token));
525 if ((size < 0LL)) {
526 r.error = std::string("malformed chunk size");
527 return std::string("");
528 }
529 (pos += line_end) += 2LL;
530 if ((size == 0LL)) {
531 return body;
532 }
533 if ((builtins::len(raw) < ((pos + size) + 2LL))) {
534 r.error = std::string("connection closed inside chunked body");
535 return std::string("");
536 }
537 body += builtins::slice(raw, pos, (pos + size));
538 (pos += size) += 2LL;
539 }
543/**
544 * Read the whole response from a connected socket (or TLS session for https).
545 *
546 * Reads every byte until the peer closes — or the socket timeout set by request_once
547 * fires, which recv reports as "" (indistinguishable from close by design: we always
548 * request `Connection: close`, so EOF IS the end of the response). When @p conn is open
549 * the bytes are read (and decrypted) through its TLS session, mirroring the send path;
550 * a closed @p conn reads the plain socket.
551 * Stops once more than @p limit bytes have arrived so a malicious/compromised peer cannot
552 * stream an unbounded body and exhaust memory; the caller treats an over-limit read as an error.
553 * @param fd a connected socket descriptor from socket.tcp_connect.
554 * @param conn the owning tls.Conn for https (open), or a closed Conn for plain http.
555 * @param limit the maximum number of body bytes to accept before bailing out.
556 * @return the bytes received, bounded to at most one 64 KiB chunk beyond @p limit.
557 * @complexity O(n) over the response size (bounded by @p limit).
558 * @alloc allocates the received buffer (bounded by @p limit).
559 * @systest RequestsSys.BasicGet
560 */
561inline auto read_all(builtins::Value auto&& fd, builtins::Value auto&& conn, builtins::Value auto&& limit) {
562 auto raw = std::string("");
563 while (true) {
564 auto chunk = std::string("");
565 if (conn.is_open()) {
566 chunk = conn.recv(65536LL);
567 } else {
568 chunk = socket::recv(fd, 65536LL);
569 }
570 if ((builtins::len(chunk) == 0LL)) {
571 break;
572 }
573 raw += chunk;
574 if ((builtins::len(raw) > limit)) {
575 break;
576 }
577 }
578 return raw;
582/**
583 * Parse a raw HTTP/1.1 response (status line + headers + body) into `r`.
584 *
585 * Headers land in `r.headers` with LOWERCASED names and stripped values; the reason phrase
586 * fills `r.reason` and any `Set-Cookie` name=value pairs fill `r.cookies`. Body framing
587 * precedence: chunked when declared, else Content-Length, else everything to EOF (we always
588 * send `Connection: close`). A HEAD request carries no body regardless of the framing headers.
589 * Malformed input sets `r.error` and returns early.
590 * The status code and Content-Length are parsed with `parse_uint`, so a malformed value (e.g.
591 * `Content-Length: abc`, a huge overflowing number, or a negative length) sets `error` instead
592 * of throwing out of the request path; a Content-Length beyond @p limit is rejected outright.
593 * @param raw the complete response bytes as received.
594 * @param r the Response under construction (status_code/reason/headers/cookies/body/error).
595 * @param method the request method (so HEAD skips body framing).
596 * @param limit the maximum acceptable body size in bytes.
597 * @return `r`, completed or carrying `error`.
598 * @complexity O(n) over the response size.
599 * @alloc allocates the parsed headers and body.
600 * @systest RequestsSys.EofFraming
601 * @systest RequestsSys.HeaderLookup
602 */
603inline auto parse_response(builtins::Value auto&& raw, builtins::Value auto&& r, builtins::Value auto&& method, builtins::Value auto&& limit) {
604 auto head_end = string::find(raw, std::string("\r\n\r\n"));
605 if ((head_end < 0LL)) {
606 r.error = std::string("connection closed before a complete response head");
607 return r;
608 }
609 if ((string::find(raw, std::string("HTTP/")) != 0LL)) {
610 r.error = std::string("malformed response head");
611 return r;
612 }
613 auto head_block = builtins::slice(raw, 0LL, head_end);
614 auto line_end = string::find(head_block, std::string("\r\n"));
615 if ((line_end < 0LL)) {
616 line_end = builtins::len(head_block);
617 }
618 auto space = string::find(head_block, std::string(" "));
619 if (((space < 0LL) || ((space + 4LL) > line_end))) {
620 r.error = std::string("malformed status line");
621 return r;
622 }
623 auto code = parse_uint(builtins::slice(head_block, (space + 1LL), (space + 4LL)));
624 if ((code < 0LL)) {
625 r.error = std::string("malformed status code");
626 return r;
627 }
628 r.status_code = code;
629 if (((space + 5LL) <= line_end)) {
630 r.reason = string::strip(builtins::slice(head_block, (space + 5LL), line_end));
631 }
632 auto hend = builtins::len(head_block);
633 auto hpos = (line_end + 2LL);
634 while ((hpos < hend)) {
635 auto eol = string::find(head_block, std::string("\r\n"), hpos);
636 if ((eol < 0LL)) {
637 eol = hend;
638 }
639 auto line = builtins::slice(head_block, hpos, eol);
640 auto colon = string::find(line, std::string(":"));
641 if ((colon > 0LL)) {
642 auto name = string::lower(string::strip(builtins::slice(line, 0LL, colon)));
643 auto value = string::strip(builtins::slice(line, (colon + 1LL), builtins::slice_end));
644 r.headers[name] = value;
645 if ((name == std::string("set-cookie"))) {
646 auto semi = string::find(value, std::string(";"));
647 auto pair = value;
648 if ((semi >= 0LL)) {
649 pair = builtins::slice(value, 0LL, semi);
650 }
651 auto eq = string::find(pair, std::string("="));
652 if ((eq > 0LL)) {
653 r.cookies[string::strip(builtins::slice(pair, 0LL, eq))] = string::strip(builtins::slice(pair, (eq + 1LL), builtins::slice_end));
654 }
655 }
656 }
657 if ((eol == hend)) {
658 break;
659 }
660 hpos = (eol + 2LL);
661 }
662 if ((method == std::string("HEAD"))) {
663 r.body = std::string("");
664 return r;
665 }
666 auto body = builtins::slice(raw, (head_end + 4LL), builtins::slice_end);
667 if ((r.header(std::string("transfer-encoding")) == std::string("chunked"))) {
668 r.body = dechunk(body, r);
669 return r;
670 }
671 auto cl = r.header(std::string("content-length"));
672 if ((cl != std::string(""))) {
673 auto n = parse_uint(cl);
674 if ((n < 0LL)) {
675 r.error = std::string("invalid Content-Length");
676 return r;
677 }
678 if ((n > limit)) {
679 r.error = std::string("Content-Length exceeds max_bytes");
680 return r;
681 }
682 if ((builtins::len(body) < n)) {
683 r.error = std::string("connection closed before the complete body arrived");
684 return r;
685 }
686 r.body = builtins::slice(body, 0LL, n);
687 } else {
688 r.body = body;
689 }
690 return r;
694/**
695 * One request exchange against an already-parsed URL (no redirect handling).
696 *
697 * Connects, applies the timeout, appends `o.params` percent-encoded to the request-target,
698 * builds the body (json_body -> data -> body; GET/HEAD send none), sends the request (custom
699 * headers, auto Content-Type/Content-Length/Authorization, default User-Agent, `Connection:
700 * close`), then reads and parses the full response. Transport failures come back in `r.error`.
701 * @param method the HTTP method ("GET", "POST", …).
702 * @param u the parsed URL (scheme/host/port/target) from parsers.url.
703 * @param o per-request options (timeout, params, headers, body, auth).
704 * @param r the Response under construction.
705 * @return the completed Response (a non-2xx status is a completed exchange, not an error).
706 * @complexity O(request + response bytes) (+ one TLS handshake for https).
707 * @alloc allocates the request and response buffers (+ a `tls` session for https).
708 * @concurrency blocking — connect/handshake/read are all bounded by the socket timeout
709 * (`o.timeout_ms`, default 30 s).
710 * @systest RequestsSys.PostJson
711 */
712inline auto request_once(builtins::Value auto&& method, builtins::Value auto&& u, builtins::Value auto&& o, builtins::Value auto&& r) {
713 auto fd = socket::tcp_connect(u.host, u.port);
714 if ((fd < 0LL)) {
715 r.error = (((((std::string("connect to ") + builtins::str(u.host)) + std::string(":")) + builtins::str(u.port)) + std::string(" failed: ")) + builtins::str(socket::last_error()));
716 return r;
717 }
718 auto timeout = o.timeout_ms;
719 if ((timeout <= 0LL)) {
720 timeout = 30000LL;
721 }
722 socket::set_timeout(fd, timeout);
723 auto conn = tls::Conn();
724 if ((u.scheme == std::string("https"))) {
725 conn = tls::open(fd, u.host, o.insecure, o.ca_file);
726 if ((!conn.is_open())) {
727 socket::close(fd);
728 r.error = (std::string("tls: ") + builtins::str(tls::last_error()));
729 return r;
730 }
731 }
732 auto body = std::string("");
733 auto ctype = std::string("");
734 if (((method != std::string("GET")) && (method != std::string("HEAD")))) {
735 if ((o.json_body != std::string(""))) {
736 body = o.json_body;
737 ctype = std::string("application/json");
738 } else {
739 if ((builtins::len(o.data) > 0LL)) {
740 auto sep = std::string("");
741 for (auto& kv : o.data) {
742 (((body += sep) += percent_encode(kv.first)) += "=") += percent_encode(kv.second);
743 sep = std::string("&");
744 }
745 ctype = std::string("application/x-www-form-urlencoded");
746 } else {
747 if ((o.body != std::string(""))) {
748 body = o.body;
749 }
750 }
751 }
752 }
753 auto target = u.target;
754 auto sep = std::string("?");
755 if ((string::find(target, std::string("?")) >= 0LL)) {
756 sep = std::string("&");
757 }
758 for (auto& kv : o.params) {
759 (((target += sep) += percent_encode(kv.first)) += "=") += percent_encode(kv.second);
760 sep = std::string("&");
761 }
762 if ((has_control_bytes(target) || has_control_bytes(u.host))) {
763 conn.close();
764 socket::close(fd);
765 r.error = std::string("refused: control bytes in the request target");
766 return r;
767 }
768 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"));
769 for (auto& kv : o.headers) {
770 if ((has_control_bytes(kv.first) || has_control_bytes(kv.second))) {
771 conn.close();
772 socket::close(fd);
773 r.error = std::string("refused: control bytes in a request header");
774 return r;
775 }
776 (((req += kv.first) += ": ") += kv.second) += "\r\n";
777 }
778 if ((!has_header(o.headers, std::string("User-Agent")))) {
779 req += "User-Agent: cheatah-requests/1.2\r\n";
780 }
781 if (((o.auth_user != std::string("")) && (!has_header(o.headers, std::string("Authorization"))))) {
782 ((req += "Authorization: Basic ") += hashlib::base64_encode(((builtins::str(o.auth_user) + std::string(":")) + builtins::str(o.auth_pass)))) += "\r\n";
783 }
784 if (((ctype != std::string("")) && (!has_header(o.headers, std::string("Content-Type"))))) {
785 ((req += "Content-Type: ") += ctype) += "\r\n";
786 }
787 if ((!has_header(o.headers, std::string("Content-Length")))) {
788 if (((((builtins::len(body) > 0LL) || (method == std::string("POST"))) || (method == std::string("PUT"))) || (method == std::string("PATCH")))) {
789 ((req += "Content-Length: ") += builtins::str(builtins::len(body))) += "\r\n";
790 }
791 }
792 (req += "Connection: close\r\nAccept: */*\r\n\r\n") += body;
793 auto sent = 0LL;
794 if (conn.is_open()) {
795 sent = conn.send(req);
796 } else {
797 sent = socket::sendall(fd, req);
798 }
799 if ((sent != 0LL)) {
800 conn.close();
801 socket::close(fd);
802 r.error = std::string("send failed");
803 return r;
804 }
805 auto limit = o.max_bytes;
806 if ((limit <= 0LL)) {
807 limit = 104857600LL;
808 }
809 auto raw = read_all(fd, conn, limit);
810 conn.close();
811 socket::close(fd);
812 if ((builtins::len(raw) > limit)) {
813 r.error = ((std::string("response body exceeds max_bytes (") + builtins::str(limit)) + std::string(")"));
814 return r;
815 }
816 return parse_response(raw, r, method, limit);
820/**
821 * Strip credentials and cookies from `o` — used when a redirect crosses to a different host so
822 * secrets scoped to the original host are never sent to another (the classic cross-origin
823 * redirect credential leak). Clears Basic auth and drops any `Authorization`/`Cookie` header.
824 *
825 * @param o the (per-request, already-copied) options to sanitize in place.
826 * @return nothing — @p o is modified in place (auth cleared, sensitive headers dropped).
827 * @complexity O(k) over the number of headers.
828 * @alloc allocates the rebuilt header map.
829 * @test CheatahRequests.CrossHostRedirectStripsCredentials
830 * @test CheatahRequests.SameHostRedirectKeepsCredentials
831 */
832inline auto strip_sensitive(builtins::Value auto&& o) {
833 o.auth_user = std::string("");
834 o.auth_pass = std::string("");
835 std::unordered_map<std::string, std::string> clean;
836 for (auto& kv : o.headers) {
837 auto lname = string::lower(kv.first);
838 if (((lname != std::string("authorization")) && (lname != std::string("cookie")))) {
839 clean[kv.first] = kv.second;
840 }
841 }
842 o.headers = clean;
846/**
847 * Perform an HTTP request, following up to max_redirects 3xx hops unless no_redirect.
848 *
849 * On a redirect to a DIFFERENT host, Basic-auth credentials and any `Authorization`/`Cookie`
850 * header are stripped before the next hop, so secrets are never leaked to another origin.
851 * Never raises for network conditions: every failure comes back as a Response with `error`
852 * set (and status_code 0). Redirects (301/302/303/307/308) follow absolute and host-relative
853 * Location targets, recording each hop in the returned Response's `history`; 303 (and 301/302
854 * on a POST) switch the method to GET and drop the body, matching Python. Set
855 * `o.no_redirect = true` to return the 3xx response directly.
856 * @param method the HTTP method ("GET", "POST", …).
857 * @param url the absolute `http(s)://host[:port]/path[?query]` URL.
858 * @param o per-request options; defaults to a 30 s timeout and 5 redirect hops (redirects followed unless no_redirect).
859 * @return the final Response — check `ok()`, then `status_code`/`headers`/`body`.
860 * @complexity one full exchange (request_once) per hop, at most 1 + max_redirects hops.
861 * @alloc allocates each hop's request/response buffers, the recorded `history`, and a
862 * private copy of @p o (so redirect-time credential stripping never mutates the caller's).
863 * @concurrency blocking, with every hop's socket I/O bounded by `timeout_ms`; no shared
864 * state — concurrent requests from separate threads are independent.
865 * @systest RequestsSys.BasicGet
866 * @systest RequestsSys.Redirect
867 * @systest RequestsSys.RedirectLoop
868 */
869inline auto request(builtins::Value auto&& method, builtins::Value auto&& url, builtins::Value auto&& o) {
870 auto opts = o;
871 auto max_hops = opts.max_redirects;
872 if ((max_hops <= 0LL)) {
873 max_hops = 5LL;
874 }
875 auto current = url;
876 auto cur_method = method;
877 auto hop = 0LL;
878 auto origin_host = std::string("");
879 std::vector<Response> hist;
880 while ((hop <= max_hops)) {
881 auto r = Response{.url = static_cast<std::string>(current)};
882 auto parser = parsers::url::Parser();
883 auto u = parsers::url::Url();
884 if ((!parser.parse(current, u))) {
885 r.error = (std::string("malformed URL: ") + builtins::str(current));
886 r.history = hist;
887 return r;
888 }
889 if ((origin_host == std::string(""))) {
890 origin_host = u.host;
891 } else {
892 if ((u.host != origin_host)) {
893 strip_sensitive(opts);
894 }
895 }
896 r = request_once(cur_method, u, opts, r);
897 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)));
898 if ((opts.no_redirect || (!redirect))) {
899 r.history = hist;
900 return r;
901 }
902 auto loc = r.header(std::string("location"));
903 if ((loc == std::string(""))) {
904 r.error = ((std::string("redirect (") + builtins::str(r.status_code)) + std::string(") without a Location header"));
905 r.history = hist;
906 return r;
907 }
908 auto next = std::string("");
909 if ((string::startswith(loc, std::string("http://")) || string::startswith(loc, std::string("https://")))) {
910 next = loc;
911 } else {
912 if (string::startswith(loc, std::string("/"))) {
913 next = (((((builtins::str(u.scheme) + std::string("://")) + builtins::str(u.host)) + std::string(":")) + builtins::str(u.port)) + builtins::str(loc));
914 } else {
915 r.error = (std::string("unsupported relative redirect Location: ") + builtins::str(loc));
916 r.history = hist;
917 return r;
918 }
919 }
920 builtins::append(hist, r);
921 {
922 auto __match_1 = r.status_code;
923 switch (__match_1) {
924 case 303LL: {
925 cur_method = std::string("GET");
926 break;
927 }
928 case 301LL: {
929 if ((cur_method == std::string("POST"))) {
930 cur_method = std::string("GET");
931 }
932 break;
933 }
934 case 302LL: {
935 if ((cur_method == std::string("POST"))) {
936 cur_method = std::string("GET");
937 }
938 break;
939 }
940 default: {
941 break;
942 }
943 }
944 }
945 current = next;
946 hop += 1LL;
947 }
948 auto r = Response{.url = static_cast<std::string>(current)};
949 r.error = ((std::string("too many redirects (max_redirects = ") + builtins::str(max_hops)) + std::string(")"));
950 r.history = hist;
951 return r;
954/**
955 * Convenience overload of `request` with the trailing default argument(s) applied.
956 * @param method as documented on the primary overload.
957 * @param url as documented on the primary overload.
958 * @return as the primary overload.
959 */
960static auto request(builtins::Value auto&& method, builtins::Value auto&& url) { return request(method, url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
962/**
963 * HTTP GET. @param url the URL. @param o per-request options.
964 * @return the final Response. @complexity one request plus any redirects.
965 * @alloc request/response buffers. @systest RequestsSys.BasicGet
966 */
967inline auto get(builtins::Value auto&& url, builtins::Value auto&& o) {
968 return request(std::string("GET"), url, o);
971/**
972 * Convenience overload of `get` with the trailing default argument(s) applied.
973 * @param url as documented on the primary overload.
974 * @return as the primary overload.
975 */
976static auto get(builtins::Value auto&& url) { return get(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
978/**
979 * HTTP POST. @param url the URL. @param o per-request options (body via json_body/data/body).
980 * @return the final Response. @complexity one request plus any redirects.
981 * @alloc request/response buffers. @systest RequestsSys.PostJson
982 */
983inline auto post(builtins::Value auto&& url, builtins::Value auto&& o) {
984 return request(std::string("POST"), url, o);
987/**
988 * Convenience overload of `post` with the trailing default argument(s) applied.
989 * @param url as documented on the primary overload.
990 * @return as the primary overload.
991 */
992static auto post(builtins::Value auto&& url) { return post(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
994/**
995 * HTTP PUT. @param url the URL. @param o per-request options (body via json_body/data/body).
996 * @return the final Response. @complexity one request plus any redirects.
997 * @alloc request/response buffers. @systest RequestsSys.PostJson
998 */
999inline auto put(builtins::Value auto&& url, builtins::Value auto&& o) {
1000 return request(std::string("PUT"), url, o);
1003/**
1004 * Convenience overload of `put` with the trailing default argument(s) applied.
1005 * @param url as documented on the primary overload.
1006 * @return as the primary overload.
1007 */
1008static auto put(builtins::Value auto&& url) { return put(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
1010/**
1011 * HTTP PATCH. @param url the URL. @param o per-request options (body via json_body/data/body).
1012 * @return the final Response. @complexity one request plus any redirects.
1013 * @alloc request/response buffers. @systest RequestsSys.PostJson
1014 */
1015inline auto patch(builtins::Value auto&& url, builtins::Value auto&& o) {
1016 return request(std::string("PATCH"), url, o);
1019/**
1020 * Convenience overload of `patch` with the trailing default argument(s) applied.
1021 * @param url as documented on the primary overload.
1022 * @return as the primary overload.
1023 */
1024static auto patch(builtins::Value auto&& url) { return patch(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
1026/**
1027 * HTTP DELETE. @param url the URL. @param o per-request options.
1028 * @return the final Response. @complexity one request plus any redirects.
1029 * @alloc request/response buffers. @systest RequestsSys.Delete
1030 */
1031inline auto delete_(builtins::Value auto&& url, builtins::Value auto&& o) {
1032 return request(std::string("DELETE"), url, o);
1035/**
1036 * Convenience overload of `delete` with the trailing default argument(s) applied.
1037 * @param url as documented on the primary overload.
1038 * @return as the primary overload.
1039 */
1040static auto delete_(builtins::Value auto&& url) { return delete_(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
1042/**
1043 * HTTP HEAD (headers only, no body). @param url the URL. @param o per-request options.
1044 * @return the final Response (empty body). @complexity one request plus any redirects.
1045 * @alloc request/response buffers. @systest RequestsSys.Head
1046 */
1047inline auto head(builtins::Value auto&& url, builtins::Value auto&& o) {
1048 return request(std::string("HEAD"), url, o);
1051/**
1052 * Convenience overload of `head` with the trailing default argument(s) applied.
1053 * @param url as documented on the primary overload.
1054 * @return as the primary overload.
1055 */
1056static auto head(builtins::Value auto&& url) { return head(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
1058/**
1059 * HTTP OPTIONS. @param url the URL. @param o per-request options.
1060 * @return the final Response. @complexity one request plus any redirects.
1061 * @alloc request/response buffers. @test CheatahRequests.VerbMethods
1062 */
1063inline auto options(builtins::Value auto&& url, builtins::Value auto&& o) {
1064 return request(std::string("OPTIONS"), url, o);
1067/**
1068 * Convenience overload of `options` with the trailing default argument(s) applied.
1069 * @param url as documented on the primary overload.
1070 * @return as the primary overload.
1071 */
1072static auto options(builtins::Value auto&& url) { return options(url, Options{.timeout_ms = static_cast<long long>(30000LL), .max_redirects = static_cast<long long>(5LL)}); }
1074/// ABI/identity marker for the `requests` cheatah module: returns the module name.
1075///
1076/// Auto-emitted by purrc's library emitter. It is the concrete symbol that
1077/// anchors the module's signed static archive in opaque (source-hidden) builds.
1078/// @return the module name (`"requests"`).
1079inline const char* module_abi() noexcept { return "requests"; }
1081} // namespace cheatah::requests