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
* @test 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 _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;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
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;431
}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.Chunked443
*/444
inline 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;466
}469
/**470
* Parse an unsigned decimal integer, safely — returns -1 on any non-digit, empty input, or a471
* value beyond 1 TiB (overflow guard). Used for the status code and Content-Length so a472
* 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.MalformedContentLengthIsError481
* @test CheatahRequests.MalformedStatusCodeIsError482
*/483
inline 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;496
}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.Chunked510
*/511
inline 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
}542
}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_once549
* fires, which recv reports as "" (indistinguishable from close by design: we always550
* request `Connection: close`, so EOF IS the end of the response). When @p conn is open551
* 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 cannot554
* 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.BasicGet562
*/563
inline 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;581
}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 phrase588
* fills `r.reason` and any `Set-Cookie` name=value pairs fill `r.cookies`. Body framing589
* precedence: chunked when declared, else Content-Length, else everything to EOF (we always590
* 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` instead594
* 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.EofFraming603
* @systest RequestsSys.HeaderLookup604
*/605
inline 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;693
}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 (custom701
* 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 timeout711
* (`o.timeout_ms`, default 30 s).712
* @systest RequestsSys.PostJson713
*/714
inline 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);819
}822
/**823
* Strip credentials and cookies from `o` — used when a redirect crosses to a different host so824
* secrets scoped to the original host are never sent to another (the classic cross-origin825
* 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.CrossHostRedirectStripsCredentials832
* @test CheatahRequests.SameHostRedirectKeepsCredentials833
*/834
inline 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;845
}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-relative855
* Location targets, recording each hop in the returned Response's `history`; 303 (and 301/302856
* on a POST) switch the method to GET and drop the body, matching Python. Set857
* `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 a864
* 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 shared866
* state — concurrent requests from separate threads are independent.867
* @systest RequestsSys.BasicGet868
* @systest RequestsSys.Redirect869
* @systest RequestsSys.RedirectLoop870
*/871
inline 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;948
}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
*/956
static 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.BasicGet962
*/963
inline auto get(builtins::Value auto&& url, builtins::Value auto&& o) {964
return request(std::string("GET"), url, o);965
}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
*/972
static 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.PostJson978
*/979
inline auto post(builtins::Value auto&& url, builtins::Value auto&& o) {980
return request(std::string("POST"), url, o);981
}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
*/988
static 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.VerbMethods999
*/1000
inline auto put(builtins::Value auto&& url, builtins::Value auto&& o) {1001
return request(std::string("PUT"), url, o);1002
}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
*/1009
static auto put(builtins::Value auto&& url) { return put(url, Options{.timeout_ms = 30000LL, .max_redirects = 5LL}); }1011
/**1012
* HTTP PATCH.1013
*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.VerbMethods1020
*/1021
inline auto patch(builtins::Value auto&& url, builtins::Value auto&& o) {1022
return request(std::string("PATCH"), url, o);1023
}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
*/1030
static 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.Delete1036
*/1037
inline auto delete_(builtins::Value auto&& url, builtins::Value auto&& o) {1038
return request(std::string("DELETE"), url, o);1039
}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
*/1046
static 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.Head1052
*/1053
inline auto head(builtins::Value auto&& url, builtins::Value auto&& o) {1054
return request(std::string("HEAD"), url, o);1055
}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
*/1062
static 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.VerbMethods1068
*/1069
inline auto options(builtins::Value auto&& url, builtins::Value auto&& o) {1070
return request(std::string("OPTIONS"), url, o);1071
}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
*/1078
static 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 that1083
/// anchors the module's signed static archive in opaque (source-hidden) builds.1084
/// @return the module name (`"requests"`).1085
inline const char* module_abi() noexcept { return "requests"; }1087
} // namespace cheatah::requests