Source
stdlib/requests/requests.hpp
1
// Generated by purrc — do not edit.2
// cheatah-deps: hashlib parsers socket string tls3
#pragma once4
#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.hpp13
*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-LIBRARY17
* MODULE WRITTEN IN PURE CHEATAH (.purr): all protocol logic below is cheatah source, compiled18
* 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`), and22
* 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` and35
* `no_redirect`, per-request timeouts, case-insensitive response headers, `Set-Cookie`36
* capture, `raise_for_status()`, and `https://` through the from-scratch cheatah `tls` module37
* (a TLS 1.3 client over the cheatah crypto modules — x25519, aead, hashlib HKDF; no OpenSSL;38
* it REFUSES servers it cannot authenticate).39
*/40
namespace cheatah::requests {42
namespace builtins = ::cheatah::builtins;43
namespace hashlib = ::cheatah::hashlib;44
namespace parsers = ::cheatah::parsers;45
namespace socket = ::cheatah::socket;46
namespace string = ::cheatah::string;47
namespace 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 from55
* the first set of `json_body` -> `data` -> `body`; `Content-Type`/`Content-Length` are added56
* 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.QueryParams59
* @systest RequestsSys.CustomHeaders60
*/61
struct 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 that105
* streams more (or declares a larger Content-Length) fails with an error instead of letting106
* 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 stays125
* "" 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.NotFound129
* @systest RequestsSys.ErrorPaths130
*/131
struct 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 completed154
* 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); empty163
* 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.BasicGet173
*/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.HeaderLookup185
*/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.BasicGet200
*/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.BasicGet211
*/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 is219
* synthesized by purrc, so parsing goes straight into fields with no dynamic DOM. (A220
* 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.JsonIntegration226
*/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.RaiseForStatus237
*/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.AllowRedirectsFalse250
*/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.Redirect261
*/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)268
namespace cheatah::parsers::json {269
/** JSON schema for `Options`, synthesized by purrc from the struct's fields — powers `parsers.json.read` into this type. */270
template <> 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. */272
template <> 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::json274
namespace 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-framed280
* message, so a CR or LF reaching either one lets a caller inject headers or split the request281
* entirely. That matters most when the value is not the caller's own: a URL taken from a fetched282
* document or a `Location` header is attacker-controlled data, and nothing else on the path283
* re-checks it. Refusing here means no consumer of this module can be made to emit a forged284
* 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.CrlfInjectionRefused291
*/292
inline auto has_control_bytes(builtins::Value auto&& text) {293
return ((string::find(text, std::string("\r")) >= 0LL) || (string::find(text, std::string("\n")) >= 0LL));294
}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.QueryParams307
*/308
inline 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;321
}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 raw328
* byte below 0x20 inside a string, so a value carrying one produced output that is not JSON, and a329
* strict parser rejects the whole document rather than the one field. This escaper was correct for330
* text somebody typed and wrong for anything that had been anywhere else — an id, a token, a header331
* 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.PostJson338
*/339
inline 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;387
}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.PostJson399
*/400
inline 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("}"));408
}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.CustomHeaders420
*/421
inline 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;429
}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.Chunked441
*/442
inline 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;464
}467
/**468
* Parse an unsigned decimal integer, safely — returns -1 on any non-digit, empty input, or a469
* value beyond 1 TiB (overflow guard). Used for the status code and Content-Length so a470
* 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.MalformedContentLengthIsError479
* @test CheatahRequests.MalformedStatusCodeIsError480
*/481
inline 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;494
}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.Chunked508
*/509
inline 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
}540
}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_once547
* fires, which recv reports as "" (indistinguishable from close by design: we always548
* request `Connection: close`, so EOF IS the end of the response). When @p conn is open549
* 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 cannot552
* 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.BasicGet560
*/561
inline 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;579
}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 phrase586
* fills `r.reason` and any `Set-Cookie` name=value pairs fill `r.cookies`. Body framing587
* precedence: chunked when declared, else Content-Length, else everything to EOF (we always588
* 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` instead592
* 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.EofFraming601
* @systest RequestsSys.HeaderLookup602
*/603
inline 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;691
}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 (custom699
* 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 timeout709
* (`o.timeout_ms`, default 30 s).710
* @systest RequestsSys.PostJson711
*/712
inline 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);817
}820
/**821
* Strip credentials and cookies from `o` — used when a redirect crosses to a different host so822
* secrets scoped to the original host are never sent to another (the classic cross-origin823
* 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.CrossHostRedirectStripsCredentials830
* @test CheatahRequests.SameHostRedirectKeepsCredentials831
*/832
inline 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;843
}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-relative853
* Location targets, recording each hop in the returned Response's `history`; 303 (and 301/302854
* on a POST) switch the method to GET and drop the body, matching Python. Set855
* `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 a862
* 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 shared864
* state — concurrent requests from separate threads are independent.865
* @systest RequestsSys.BasicGet866
* @systest RequestsSys.Redirect867
* @systest RequestsSys.RedirectLoop868
*/869
inline 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;952
}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
*/960
static 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.BasicGet966
*/967
inline auto get(builtins::Value auto&& url, builtins::Value auto&& o) {968
return request(std::string("GET"), url, o);969
}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
*/976
static 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.PostJson982
*/983
inline auto post(builtins::Value auto&& url, builtins::Value auto&& o) {984
return request(std::string("POST"), url, o);985
}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
*/992
static 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.PostJson998
*/999
inline auto put(builtins::Value auto&& url, builtins::Value auto&& o) {1000
return request(std::string("PUT"), url, o);1001
}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
*/1008
static 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.PostJson1014
*/1015
inline auto patch(builtins::Value auto&& url, builtins::Value auto&& o) {1016
return request(std::string("PATCH"), url, o);1017
}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
*/1024
static 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.Delete1030
*/1031
inline auto delete_(builtins::Value auto&& url, builtins::Value auto&& o) {1032
return request(std::string("DELETE"), url, o);1033
}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
*/1040
static 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.Head1046
*/1047
inline auto head(builtins::Value auto&& url, builtins::Value auto&& o) {1048
return request(std::string("HEAD"), url, o);1049
}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
*/1056
static 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.VerbMethods1062
*/1063
inline auto options(builtins::Value auto&& url, builtins::Value auto&& o) {1064
return request(std::string("OPTIONS"), url, o);1065
}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
*/1072
static 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 that1077
/// anchors the module's signed static archive in opaque (source-hidden) builds.1078
/// @return the module name (`"requests"`).1079
inline const char* module_abi() noexcept { return "requests"; }1081
} // namespace cheatah::requests