Add HTTPRequest/HTTPResponse: real HTTP parsing on the Parser/StreamReader stack
Stage 2 of the APIServer plan. This codebase has only ever sat behind a CGI gateway - IMFRequest/IMFResponse/IMFMessage predate the StreamReader/Parser rebuild and are stubs (IMFResponse's header-emission calls addHeader()/output(), neither of which exist on the class; IMFRequest is a fragile non-resumable 3-token line split). Nothing in this repo has ever actually parsed a raw HTTP request off a socket. HTTPRequest : public Parser fills that gap the same way every other Parser here is built: REQUEST_LINE -> HEADERS -> BODY -> DONE, chaining LineParser (request line) and IMFHeader (headers, reused as-is - HTTP header syntax is IMF-derived, nothing email-specific in IMFHeader itself) via checkParse() over one shared StreamReader, exactly the pattern CGIFormData already established for chaining IMFHeader/ IMFBoundary. The BODY state dispatches on Content-Type as CGIFormData itself does for CGI bodies - application/x-www-form-urlencoded and multipart/form-data delegate directly to CGIFormData (reused, not reimplemented), anything else is captured as a raw body (e.g. JSON). Query string is split off the path before anything else sees it. getHeader() does a case-insensitive lookup (HTTP header names are case-insensitive per RFC 7230, unlike IMFHeader's own std::map, which compares byte-for-byte) since a route handler constantly needs this and getting it wrong would be a silent framing bug, not a cosmetic one. The boundary-extraction code mirrors __jet.cpp's own fix exactly (same cursor-mutation trap: ZString::find() leaves the searched string's cursor at the match, and CGIFormData's constructor copy-constructs its own contentType member from whatever's passed in, inheriting that same moved cursor unless reset() first - would otherwise silently miss its own startsWith() dispatch checks). HTTPResponse is a small, new, output-only builder (status line + a real headers container + body, Content-Length auto-computed unless set explicitly) - deliberately not patched from IMFResponse, which has nothing safe to extend. v1 limitations, flagged not fixed (matching the standard already set by other Parser subclasses here for known-but-out-of-scope gaps): chunked Transfer-Encoding isn't supported, and repeated header names last-wins (inherited from IMFHeader's own std::map). Verified live via testing/http_test.cpp (34 assertions, new - now wired into testing/compile): byte-at-a-time GET with a query string and case-insensitive header lookup; a GET with no query string; a POST with an urlencoded body deliberately split mid-body (not just mid-header) across two feed() calls, confirming the CGIFormData delegation resumes correctly; a raw JSON POST body; testing/http_request.data (a real fixture, previously unused - normalized from its stored LF line endings to real CRLF, since HTTP requires CRLF and IMFHeader enforces that strictly) fed byte-at-a-time; and HTTPResponse::serialize() both with an auto-computed and an explicitly-set Content-Length (no duplicate header). Also reran the full existing testing/compile suite (zstring/mstring/jstring/mfile/jfile) against the rebuilt libCoreUtils.a to confirm no regression from the two new files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e4e463a96
commit
cb845efe69
7 files changed
+521
No files matched your search
Executable → Regular
+1
@@ -4,3 +4,4 @@ g++ -g -std=c++20 -o mstring_test mstring_test.cpp -I.. -L.. -lCoreUtils -lb64
|
||||
g++ -o jstring_test jstring_test.cpp -I.. -L.. -lCoreUtils
|
||||
g++ -g -std=c++20 -o mfile_test mfile_test.cpp -I.. -L.. -lCoreUtils
|
||||
g++ -g -std=c++20 -o jfile_test jfile_test.cpp -I.. -L.. -lCoreUtils
|
||||
g++ -g -std=c++23 -o http_test http_test.cpp -I.. -L.. -lCoreUtils
|
||||
@@ -0,0 +1,179 @@
|
||||
#include <iostream>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include "../StreamReader.h"
|
||||
#include "../HTTPRequest.h"
|
||||
#include "../HTTPResponse.h"
|
||||
|
||||
using namespace coreutils;
|
||||
|
||||
static int checks = 0;
|
||||
#define CHECK(cond) do { checks++; if(!(cond)) { std::cerr << "FAIL line " << __LINE__ << ": " #cond << std::endl; return 1; } } while(0)
|
||||
|
||||
// Feeds a StreamReader one byte at a time and drives a Parser's doParse()
|
||||
// after every single byte - the harshest fragmentation test used elsewhere
|
||||
// in this subsystem (see CGIFormData's byte-at-a-time stress test) - to
|
||||
// prove HTTPRequest is genuinely resumable across arbitrary split points,
|
||||
// not just "works when handed the whole request at once".
|
||||
static Parser::Status feedByteAtATime(StreamReader &reader, Parser &parser, const std::string &data) {
|
||||
Parser::Status status = Parser::Status::INCOMPLETE;
|
||||
for (size_t i = 0; i < data.size() && status != Parser::Status::COMPLETE; ++i) {
|
||||
ZString chunk((char *)data.data() + i, 1);
|
||||
reader.feed(chunk);
|
||||
status = parser.doParse();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
int test_get_request_byte_at_a_time() {
|
||||
StreamReader reader;
|
||||
HTTPRequest req(reader);
|
||||
std::string raw =
|
||||
"GET /hello?x=1&y=two HTTP/1.1\r\n"
|
||||
"Host: example.com\r\n"
|
||||
"cookie: session=abc123\r\n"
|
||||
"\r\n";
|
||||
CHECK(feedByteAtATime(reader, req, raw) == Parser::Status::COMPLETE);
|
||||
CHECK(req.getMethod() == "GET");
|
||||
CHECK(req.getPath() == "/hello");
|
||||
CHECK(req.getQueryString() == "x=1&y=two");
|
||||
CHECK(req.getVersion() == "HTTP/1.1");
|
||||
CHECK(req.getHeader("Host") == "example.com");
|
||||
// Case-insensitive lookup: header arrived lowercase ("cookie:"), looked
|
||||
// up with the canonical HTTP capitalization ("Cookie") - real clients
|
||||
// and servers disagree on casing constantly, this must not care.
|
||||
CHECK(req.getHeader("Cookie") == "session=abc123");
|
||||
CHECK(req.getBody().getLength() == 0);
|
||||
CHECK(req.getFormData() == NULL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int test_get_request_no_query_string() {
|
||||
StreamReader reader;
|
||||
HTTPRequest req(reader);
|
||||
std::string raw = "GET /plain HTTP/1.1\r\nHost: h\r\n\r\n";
|
||||
CHECK(feedByteAtATime(reader, req, raw) == Parser::Status::COMPLETE);
|
||||
CHECK(req.getPath() == "/plain");
|
||||
CHECK(req.getQueryString().getLength() == 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int test_post_urlencoded_body_split_across_feeds() {
|
||||
StreamReader reader;
|
||||
HTTPRequest req(reader);
|
||||
std::string raw =
|
||||
"POST /submit HTTP/1.1\r\n"
|
||||
"Host: example.com\r\n"
|
||||
"Content-Type: application/x-www-form-urlencoded\r\n"
|
||||
"Content-Length: 14\r\n"
|
||||
"\r\n"
|
||||
"name=A%20B&x=1";
|
||||
// Split mid-body, not just mid-header - the whole point of building this
|
||||
// on the same StreamReader/checkParse() chain CGIFormData already uses.
|
||||
size_t splitAt = raw.size() - 5;
|
||||
ZString part1((char *)raw.data(), splitAt);
|
||||
reader.feed(part1);
|
||||
CHECK(req.doParse() == Parser::Status::INCOMPLETE);
|
||||
ZString part2((char *)raw.data() + splitAt, raw.size() - splitAt);
|
||||
reader.feed(part2);
|
||||
CHECK(req.doParse() == Parser::Status::COMPLETE);
|
||||
CHECK(req.getMethod() == "POST");
|
||||
CHECK(req.getFormData() != NULL);
|
||||
MString name = (*req.getFormData())["name"];
|
||||
CHECK(name == "A B");
|
||||
MString x = (*req.getFormData())["x"];
|
||||
CHECK(x == "1");
|
||||
CHECK(req.getBody().getLength() == 0); // consumed by the form parser, not the raw-body path
|
||||
return 0;
|
||||
}
|
||||
|
||||
int test_post_raw_json_body() {
|
||||
StreamReader reader;
|
||||
HTTPRequest req(reader);
|
||||
std::string body = "{\"a\":1}";
|
||||
std::string raw =
|
||||
"POST /api HTTP/1.1\r\n"
|
||||
"Host: example.com\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: " + std::to_string(body.size()) + "\r\n"
|
||||
"\r\n" + body;
|
||||
CHECK(feedByteAtATime(reader, req, raw) == Parser::Status::COMPLETE);
|
||||
CHECK(req.getFormData() == NULL);
|
||||
CHECK(req.getBody() == body.c_str());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int test_real_fixture_file() {
|
||||
// testing/http_request.data is a real captured request, but stored with
|
||||
// plain LF line endings (a text-editing convenience, confirmed via
|
||||
// `file`/`xxd` - not a literal wire capture); real HTTP requires CRLF
|
||||
// per RFC 7230, and IMFHeader (which HTTPRequest's HEADERS state reuses
|
||||
// as-is) enforces that strictly. Normalize LF -> CRLF before feeding,
|
||||
// same as any real client would actually send it.
|
||||
std::ifstream in("http_request.data");
|
||||
CHECK((bool)in);
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
std::string raw = ss.str();
|
||||
std::string crlf;
|
||||
crlf.reserve(raw.size() + 16);
|
||||
for (char c : raw) {
|
||||
if (c == '\n') crlf += '\r';
|
||||
crlf += c;
|
||||
}
|
||||
|
||||
StreamReader reader;
|
||||
HTTPRequest req(reader);
|
||||
CHECK(feedByteAtATime(reader, req, crlf) == Parser::Status::COMPLETE);
|
||||
CHECK(req.getMethod() == "GET");
|
||||
CHECK(req.getPath() == "/");
|
||||
CHECK(req.getHeader("Host") == "localhost:8080");
|
||||
CHECK(req.getHeader("Cookie") == "_ga=GA1.1.1017526412.1628195401");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int test_http_response_serialize() {
|
||||
HTTPResponse resp;
|
||||
resp.setStatus(200, "OK");
|
||||
resp.setHeader("Content-Type", "text/plain");
|
||||
resp.setBody("hello");
|
||||
MString out = resp.serialize();
|
||||
std::string s(out.getData(), out.getLength());
|
||||
CHECK(s.find("HTTP/1.1 200 OK\r\n") == 0);
|
||||
CHECK(s.find("Content-Type: text/plain\r\n") != std::string::npos);
|
||||
CHECK(s.find("Content-Length: 5\r\n") != std::string::npos);
|
||||
CHECK(s.find("\r\n\r\nhello") != std::string::npos);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int test_http_response_explicit_content_length_respected() {
|
||||
HTTPResponse resp;
|
||||
resp.setStatus(404, "Not Found");
|
||||
resp.setHeader("Content-Length", "0");
|
||||
resp.setBody("");
|
||||
MString out = resp.serialize();
|
||||
std::string s(out.getData(), out.getLength());
|
||||
CHECK(s.find("HTTP/1.1 404 Not Found\r\n") == 0);
|
||||
int firstCL = s.find("Content-Length:");
|
||||
int lastCL = s.rfind("Content-Length:");
|
||||
CHECK(firstCL == lastCL); // no duplicate Content-Length header
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main() {
|
||||
int rc = 0;
|
||||
rc |= test_get_request_byte_at_a_time();
|
||||
rc |= test_get_request_no_query_string();
|
||||
rc |= test_post_urlencoded_body_split_across_feeds();
|
||||
rc |= test_post_raw_json_body();
|
||||
rc |= test_real_fixture_file();
|
||||
rc |= test_http_response_serialize();
|
||||
rc |= test_http_response_explicit_content_length_respected();
|
||||
if (rc == 0)
|
||||
std::cout << "ALL " << checks << " CHECKS PASSED" << std::endl;
|
||||
else
|
||||
std::cout << "SOME CHECKS FAILED (see above)" << std::endl;
|
||||
return rc;
|
||||
}
|
||||
Reference in new issue
Block a user