cheatah
Source

tests/purrc/requests_sys_test.cpp

1// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).
2// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.
3// System tests for `requests` — THE FIRST PURE-CHEATAH STDLIB MODULE. Each test runs a real
4// loopback HTTP server (cheatah::socket, C++ thread), then compiles + runs a .purr program
5// that `import requests` and GETs from it over a genuine TCP connection, asserting stdout.
6#include <string>
7#include <thread>
9#include "e2e_harness.hpp"
10#include "socket.hpp"
12namespace sock = cheatah::socket;
14namespace {
16// Accept one connection, read the request head, send @p response verbatim, close.
17// @complexity O(1) @alloc the request buffer @test RequestsSys (helper)
18void serve_once(long long listen_fd, std::string response) {
19 const long long client = sock::accept(listen_fd);
20 if (client < 0) return;
21 std::string request;
22 while (request.find("\r\n\r\n") == std::string::npos) {
23 const std::string chunk = sock::recv(client, 4096);
24 if (chunk.empty()) break;
25 request += chunk;
26 }
27 sock::sendall(client, response);
28 sock::close(client);
31} // namespace
33// GET against a live server: status, ok(), body — through pure-cheatah HTTP.
34TEST(RequestsSys, BasicGet) {
35 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
36 ASSERT_GE(fd, 0);
37 const long long port = sock::local_port(fd);
38 std::thread server(serve_once, fd,
39 "HTTP/1.1 200 OK\r\nContent-Length: 18\r\n\r\nhello from cheatah");
40 const std::string src = "import requests\nimport io\n"
41 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
42 "/greeting\")\n"
43 "io.print(r.status_code)\nio.print(r.ok())\nio.print(r.body)\n";
44 e2e::expect_e2e("requests_basic_get", src, "200\nTrue\nhello from cheatah\n");
45 server.join();
46 sock::close(fd);
49// A 404 is a COMPLETED exchange: ok() false, error empty, body real.
50TEST(RequestsSys, NotFound) {
51 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
52 ASSERT_GE(fd, 0);
53 const long long port = sock::local_port(fd);
54 std::thread server(serve_once, fd, "HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\n\r\nnope");
55 const std::string src = "import requests\nimport io\n"
56 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
57 "/missing\")\n"
58 "io.print(r.status_code)\nio.print(r.ok())\nio.print(r.error == \"\")\n"
59 "io.print(r.body)\n";
60 e2e::expect_e2e("requests_not_found", src, "404\nFalse\nTrue\nnope\n");
61 server.join();
62 sock::close(fd);
65// No Content-Length: the body is framed by connection close.
66TEST(RequestsSys, EofFraming) {
67 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
68 ASSERT_GE(fd, 0);
69 const long long port = sock::local_port(fd);
70 std::thread server(serve_once, fd, "HTTP/1.1 200 OK\r\n\r\nuntil the very end");
71 const std::string src = "import requests\nimport io\n"
72 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
73 "/\")\nio.print(r.body)\n";
74 e2e::expect_e2e("requests_eof", src, "until the very end\n");
75 server.join();
76 sock::close(fd);
79// Error paths need no server: malformed URL, connection refused.
80TEST(RequestsSys, ErrorPaths) {
81 e2e::expect_e2e("requests_errors", R"PURR(import requests
82import io
83let b = requests.get("not a url")
84io.print(b.error == "")
85let c = requests.get("http://127.0.0.1:9/")
86io.print(c.error == "")
87)PURR", "False\nFalse\n");
90// https against a peer that is not a TLS server: refused with a tls error, never silent.
91TEST(RequestsSys, HttpsRefusesBadPeer) {
92 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
93 ASSERT_GE(fd, 0);
94 const long long port = sock::local_port(fd);
95 std::thread peer([fd]() {
96 const long long client = sock::accept(fd);
97 sock::sendall(client, "plain text, not TLS\r\n");
98 sock::close(client);
99 });
100 const std::string src = "import requests\nimport io\nimport string\n"
101 "let o = requests.Options({.timeout_ms = 3000})\n"
102 "let r = requests.get(\"https://127.0.0.1:" + std::to_string(port) +
103 "/\", o)\nio.print(r.status_code)\nio.print(string.contains(r.error, \"tls\"))\n";
104 e2e::expect_e2e("requests_https_bad_peer", src, "0\nTrue\n");
105 peer.join();
106 sock::close(fd);
109// ---- parity matrix (ported from the C++ reference implementation's test suite) ----
111// Chunked transfer-encoding is decoded (hex sizes, extensions ignored, trailer consumed).
112TEST(RequestsSys, Chunked) {
113 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
114 ASSERT_GE(fd, 0);
115 const long long port = sock::local_port(fd);
116 std::thread server(serve_once, fd,
117 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
118 "4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n");
119 const std::string src = "import requests\nimport io\n"
120 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
121 "/\")\nio.print(r.ok())\nio.print(r.body)\n";
122 e2e::expect_e2e("requests_chunked", src, "True\nWikipedia\n");
123 server.join();
124 sock::close(fd);
127// Response headers are stored lowercased: lookup is case-insensitive either way.
128TEST(RequestsSys, HeaderLookup) {
129 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
130 ASSERT_GE(fd, 0);
131 const long long port = sock::local_port(fd);
132 std::thread server(serve_once, fd,
133 "HTTP/1.1 200 OK\r\nContent-Length: 1\r\nX-Custom-Tag: abc123\r\n\r\nx");
134 const std::string src = "import requests\nimport io\n"
135 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
136 "/\")\n"
137 "io.print(r.header(\"x-custom-tag\"))\n"
138 "io.print(r.header(\"X-CUSTOM-TAG\"))\n"
139 "io.print(r.header(\"absent\") == \"\")\n";
140 e2e::expect_e2e("requests_headers", src, "abc123\nabc123\nTrue\n");
141 server.join();
142 sock::close(fd);
145// Query params are appended and percent-encoded ('&', space, '~' unreserved).
146TEST(RequestsSys, QueryParams) {
147 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
148 ASSERT_GE(fd, 0);
149 const long long port = sock::local_port(fd);
150 std::string captured;
151 std::thread server([fd, &captured]() {
152 const long long client = sock::accept(fd);
153 while (captured.find("\r\n\r\n") == std::string::npos) {
154 const std::string chunk = sock::recv(client, 4096);
155 if (chunk.empty()) break;
156 captured += chunk;
157 }
158 sock::sendall(client, "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx");
159 sock::close(client);
160 });
161 const std::string full = "import requests\nimport io\n"
162 "let o = requests.Options({.timeout_ms = 5000})\n"
163 "o.params[\"symbol\"] = \"S&P 500~\"\n"
164 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
165 "/q?fixed=1\", o)\nio.print(r.ok())\n";
166 e2e::expect_e2e("requests_params", full, "True\n");
167 server.join();
168 sock::close(fd);
169 EXPECT_NE(captured.find("GET /q?fixed=1&symbol=S%26P%20500~ HTTP/1.1"), std::string::npos)
170 << captured;
173// Custom request headers go on the wire.
174TEST(RequestsSys, CustomHeaders) {
175 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
176 ASSERT_GE(fd, 0);
177 const long long port = sock::local_port(fd);
178 std::string captured;
179 std::thread server([fd, &captured]() {
180 const long long client = sock::accept(fd);
181 while (captured.find("\r\n\r\n") == std::string::npos) {
182 const std::string chunk = sock::recv(client, 4096);
183 if (chunk.empty()) break;
184 captured += chunk;
185 }
186 sock::sendall(client, "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx");
187 sock::close(client);
188 });
189 const std::string full = "import requests\nimport io\n"
190 "let o = requests.Options({.timeout_ms = 5000})\n"
191 "o.headers[\"X-Api-Key\"] = \"secret\"\n"
192 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
193 "/\", o)\nio.print(r.ok())\n";
194 e2e::expect_e2e("requests_custom_headers", full, "True\n");
195 server.join();
196 sock::close(fd);
197 EXPECT_NE(captured.find("X-Api-Key: secret\r\n"), std::string::npos) << captured;
200// 302 with a path-absolute Location is followed; the final URL lands in r.url.
201TEST(RequestsSys, Redirect) {
202 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
203 ASSERT_GE(fd, 0);
204 const long long port = sock::local_port(fd);
205 std::thread server([fd]() {
206 serve_once(fd, "HTTP/1.1 302 Found\r\nLocation: /moved\r\nContent-Length: 0\r\n\r\n");
207 serve_once(fd, "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nfound me");
208 });
209 const std::string src = "import requests\nimport io\nimport string\n"
210 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
211 "/start\")\nio.print(r.ok())\nio.print(r.body)\n"
212 "io.print(string.contains(r.url, \"/moved\"))\n";
213 e2e::expect_e2e("requests_redirect", src, "True\nfound me\nTrue\n");
214 server.join();
215 sock::close(fd);
218// A redirect loop stops at max_redirects with a clear error.
219TEST(RequestsSys, RedirectLoop) {
220 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
221 ASSERT_GE(fd, 0);
222 const long long port = sock::local_port(fd);
223 std::thread server([fd]() {
224 for (int i = 0; i < 4; ++i)
225 serve_once(fd, "HTTP/1.1 302 Found\r\nLocation: /again\r\nContent-Length: 0\r\n\r\n");
226 });
227 const std::string full = "import requests\nimport io\nimport string\n"
228 "let o = requests.Options({.timeout_ms = 5000, .max_redirects = 3})\n"
229 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
230 "/start\", o)\nio.print(r.ok())\n"
231 "io.print(string.contains(r.error, \"too many redirects\"))\n";
232 e2e::expect_e2e("requests_redirect_loop", full, "False\nTrue\n");
233 server.join();
234 sock::close(fd);
237// A server that never answers trips the socket timeout — bounded, not hung.
238TEST(RequestsSys, Timeout) {
239 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
240 ASSERT_GE(fd, 0);
241 const long long port = sock::local_port(fd);
242 std::thread server([fd]() {
243 const long long client = sock::accept(fd);
244 std::this_thread::sleep_for(std::chrono::milliseconds(800));
245 sock::close(client);
246 });
247 const std::string full = "import requests\nimport io\n"
248 "let o = requests.Options({.timeout_ms = 150})\n"
249 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
250 "/slow\", o)\nio.print(r.ok())\nio.print(r.error == \"\")\n";
251 const auto start = std::chrono::steady_clock::now();
252 e2e::expect_e2e("requests_timeout", full, "False\nFalse\n");
253 const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
254 std::chrono::steady_clock::now() - start);
255 // NOT a latency assertion: `elapsed` spans expect_e2e's whole purrc compile + link +
256 // run, which dwarfs the 150 ms request and scales with machine load (this tripped at
257 // 5 s during a parallel sanitizer run). It is a backstop against an UNBOUNDED hang —
258 // a request that ignored the timeout would block on the server's 800 ms sleep and,
259 // if the timeout were broken outright, never return. The timeout's real proof is the
260 // expected "False\nFalse" above: the request failed instead of completing.
261 EXPECT_LT(elapsed.count(), 120000) << "the request never returned — timeout did not bound it";
262 server.join();
263 sock::close(fd);
266// End to end: GET a JSON body, then parse it STRAIGHT into a .purr struct via the typed
267// reader (the schema is synthesized by purrc) — requests + parsers composing in pure cheatah.
268TEST(RequestsSys, JsonIntegration) {
269 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
270 ASSERT_GE(fd, 0);
271 const long long port = sock::local_port(fd);
272 std::thread server(serve_once, fd,
273 "HTTP/1.1 200 OK\r\nContent-Length: 44\r\n"
274 "Content-Type: application/json\r\n\r\n"
275 R"({"symbol":"SPX","price":7386.65,"live":true})");
276 const std::string src = "import requests\nimport parsers\nimport io\n"
277 "struct Quote {\n symbol: str\n price: float\n live: bool\n}\n"
278 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
279 "/quote\")\n"
280 "let q = Quote(\"\", 0.0, false)\n"
281 "if r.ok() and parsers.json.read(r.body, q) {\n"
282 " io.print(q.symbol)\n io.print(q.price)\n io.print(q.live)\n}\n";
283 e2e::expect_e2e("requests_json", src, "SPX\n7386.65\nTrue\n");
284 server.join();
285 sock::close(fd);
288// Accept one connection, read the head AND any Content-Length body into @p captured, reply.
289// @complexity O(request size) @alloc the request buffer @test RequestsSys (helper)
290static void serve_capture(long long listen_fd, std::string* captured, std::string response) {
291 const long long client = sock::accept(listen_fd);
292 if (client < 0) return;
293 while (captured->find("\r\n\r\n") == std::string::npos) {
294 const std::string chunk = sock::recv(client, 4096);
295 if (chunk.empty()) break;
296 *captured += chunk;
297 }
298 const std::size_t he = captured->find("\r\n\r\n");
299 const std::size_t clp = captured->find("Content-Length:");
300 if (he != std::string::npos && clp != std::string::npos && clp < he) {
301 const long long want = std::atoll(captured->c_str() + clp + 15);
302 while (want > 0 && static_cast<long long>(captured->size() - (he + 4)) < want) {
303 const std::string chunk = sock::recv(client, 4096);
304 if (chunk.empty()) break;
305 *captured += chunk;
306 }
307 }
308 sock::sendall(client, response);
309 sock::close(client);
312// POST a JSON body built with to_json: the wire carries POST + application/json + the body.
313TEST(RequestsSys, PostJson) {
314 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
315 ASSERT_GE(fd, 0);
316 const long long port = sock::local_port(fd);
317 std::string captured;
318 std::thread server(serve_capture, fd, &captured,
319 std::string("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"));
320 const std::string src = "import requests\nimport io\n"
321 "let o = requests.Options({.json_body = requests.to_json({\"side\": \"buy\"})})\n"
322 "let r = requests.post(\"http://127.0.0.1:" + std::to_string(port) +
323 "/order\", o)\nio.print(r.status_code)\nio.print(r.ok())\n";
324 e2e::expect_e2e("requests_post_json", src, "200\nTrue\n");
325 server.join();
326 sock::close(fd);
327 EXPECT_EQ(captured.rfind("POST /order ", 0), 0u) << captured;
328 EXPECT_NE(captured.find("Content-Type: application/json\r\n"), std::string::npos) << captured;
329 EXPECT_NE(captured.find("\r\n\r\n{\"side\":\"buy\"}"), std::string::npos) << captured;
332// HTTP Basic auth puts the base64 Authorization header on the wire.
333TEST(RequestsSys, BasicAuth) {
334 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
335 ASSERT_GE(fd, 0);
336 const long long port = sock::local_port(fd);
337 std::string captured;
338 std::thread server(serve_capture, fd, &captured,
339 std::string("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"));
340 const std::string src = "import requests\nimport io\n"
341 "let o = requests.Options({.auth_user = \"user\", .auth_pass = \"pass\"})\n"
342 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
343 "/a\", o)\nio.print(r.ok())\n";
344 e2e::expect_e2e("requests_basic_auth", src, "True\n");
345 server.join();
346 sock::close(fd);
347 EXPECT_NE(captured.find("Authorization: Basic dXNlcjpwYXNz\r\n"), std::string::npos) << captured;
350// requests.delete() sends the DELETE method (the verb name is a C++ keyword, escaped by codegen).
351TEST(RequestsSys, Delete) {
352 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
353 ASSERT_GE(fd, 0);
354 const long long port = sock::local_port(fd);
355 std::string captured;
356 std::thread server(serve_capture, fd, &captured,
357 std::string("HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\ndeleted"));
358 const std::string src = "import requests\nimport io\n"
359 "let r = requests.delete(\"http://127.0.0.1:" + std::to_string(port) +
360 "/thing\")\nio.print(r.status_code)\nio.print(r.text())\n";
361 e2e::expect_e2e("requests_delete", src, "200\ndeleted\n");
362 server.join();
363 sock::close(fd);
364 EXPECT_EQ(captured.rfind("DELETE /thing ", 0), 0u) << captured;
367// HEAD yields headers only: an empty body even with a declared Content-Length.
368TEST(RequestsSys, Head) {
369 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
370 ASSERT_GE(fd, 0);
371 const long long port = sock::local_port(fd);
372 std::thread server(serve_once, fd, "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n");
373 const std::string src = "import requests\nimport io\n"
374 "let r = requests.head(\"http://127.0.0.1:" + std::to_string(port) +
375 "/h\")\nio.print(r.status_code)\nio.print(len(r.body))\n";
376 e2e::expect_e2e("requests_head", src, "200\n0\n");
377 server.join();
378 sock::close(fd);
381// raise_for_status() raises on a 5xx — caught by a cheatah try/except.
382TEST(RequestsSys, RaiseForStatus) {
383 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
384 ASSERT_GE(fd, 0);
385 const long long port = sock::local_port(fd);
386 std::thread server(serve_once, fd, "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n");
387 const std::string src = "import requests\nimport io\n"
388 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
389 "/e\")\ntry {\n r.raise_for_status()\n io.print(\"no raise\")\n} except e {\n io.print(\"caught\")\n}\n";
390 e2e::expect_e2e("requests_raise_for_status", src, "caught\n");
391 server.join();
392 sock::close(fd);
395// no_redirect returns the 3xx directly instead of following it.
396TEST(RequestsSys, AllowRedirectsFalse) {
397 const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);
398 ASSERT_GE(fd, 0);
399 const long long port = sock::local_port(fd);
400 std::thread server(serve_once, fd,
401 "HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n");
402 const std::string src = "import requests\nimport io\n"
403 "let o = requests.Options({.no_redirect = true})\n"
404 "let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +
405 "/start\", o)\nio.print(r.status_code)\nio.print(r.is_redirect())\n";
406 e2e::expect_e2e("requests_no_follow", src, "302\nTrue\n");
407 server.join();
408 sock::close(fd);