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:
Brad ArantandClaude Sonnet 5 committed 2026-08-25 10:42:34 -07:00
1 parent 6e4e463a96
commit cb845efe69
7 files changed
+521

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ build/
.history/ .history/
testing/mstring_test testing/mstring_test
testing/zstring_test testing/zstring_test
testing/http_test
+127
View File
@@ -0,0 +1,127 @@
#include "HTTPRequest.h"
#include "Exception.h"
#include <strings.h>
namespace coreutils {
HTTPRequest::HTTPRequest(StreamReader &reader) : Parser(reader) {}
HTTPRequest::~HTTPRequest() {
delete lineParser;
delete headerParser;
delete formParser;
}
Parser::Status HTTPRequest::parse() {
MString &buffer = reader.getBuffer();
switch (state) {
case REQUEST_LINE: {
if (!lineParser)
lineParser = new LineParser(reader);
if (!checkParse(*lineParser))
return Status::INCOMPLETE;
{
ZString line = lineParser->getLine();
ZString sp(" ");
std::vector<ZString> &parts = line.split(sp);
if (parts.size() != 3)
throw coreutils::Exception("HTTPRequest: malformed request line "
"(expected \"METHOD /path HTTP/1.1\").");
method = MString(parts[0]);
path = MString(parts[1]);
version = MString(parts[2]);
}
delete lineParser;
lineParser = NULL;
{
// Split the query string off before anything else sees `path` -
// a route table (see the APIServer plan) should never have to
// deal with a trailing "?a=1&b=2" itself.
int qpos = path.find(ZString("?"));
if (qpos != -1) {
queryString = MString(path.substring(qpos + 1));
path = MString(path.substring(0, qpos));
}
}
state = HEADERS;
return Status::DATA_AVAIL;
}
case HEADERS:
if (!headerParser)
headerParser = new IMFHeader(reader);
if (!checkParse(*headerParser))
return Status::INCOMPLETE;
headers = headerParser->getHeaders();
delete headerParser;
headerParser = NULL;
state = BODY;
return Status::DATA_AVAIL;
case BODY: {
MString contentLengthHeader = getHeader("Content-Length");
if (contentLengthHeader.getLength() == 0) {
if (getHeader("Transfer-Encoding").getLength() > 0)
throw coreutils::Exception("HTTPRequest: chunked Transfer-Encoding "
"is not supported (Content-Length only).");
state = DONE;
return Status::COMPLETE;
}
int contentLength = contentLengthHeader.asInteger();
MString contentType = getHeader("Content-Type");
if (contentType.startsWith("application/x-www-form-urlencoded") ||
contentType.startsWith("multipart/form-data")) {
if (!formParser) {
// Mirrors __jet.cpp's identical CGI-path fix: find() leaves
// contentType's own cursor at the match, and CGIFormData's
// constructor copy-constructs its own contentType member
// from whatever we pass, inheriting that same moved cursor
// - its startsWith() dispatch checks are cursor-relative
// and would silently miss (falling through to UNKNOWN)
// without the reset().
int boundaryPos = contentType.find(ZString("boundary="));
if (boundaryPos != -1)
boundary = MString(contentType.substring(boundaryPos + 9));
contentType.reset();
formParser = new CGIFormData(reader, contentType, contentLength, boundary);
}
if (!checkParse(*formParser))
return Status::INCOMPLETE;
state = DONE;
return Status::COMPLETE;
}
if ((int)buffer.getLength() < contentLength)
return Status::INCOMPLETE;
body = MString(ZString(buffer.getCursor(), contentLength));
buffer.setCursor(buffer.getCursor() + contentLength);
state = DONE;
return Status::COMPLETE;
}
case DONE:
return Status::COMPLETE;
}
throw coreutils::Exception("HTTPRequest: unreachable state.");
}
MString HTTPRequest::getMethod() { return method; }
MString HTTPRequest::getPath() { return path; }
MString HTTPRequest::getQueryString() { return queryString; }
MString HTTPRequest::getVersion() { return version; }
MString HTTPRequest::getBody() { return body; }
std::map<MString, MString> &HTTPRequest::getHeaders() { return headers; }
CGIFormData *HTTPRequest::getFormData() { return formParser; }
MString HTTPRequest::getHeader(const char *name) {
for (auto &pair : headers) {
MString key = pair.first;
if (strcasecmp(key.c_str(), name) == 0)
return pair.second;
}
return MString();
}
}
+109
View File
@@ -0,0 +1,109 @@
#ifndef __HTTPRequest_h__
#define __HTTPRequest_h__
#include "Parser.h"
#include "LineParser.h"
#include "IMFHeader.h"
#include "CGIFormData.h"
#include "MString.h"
#include <map>
namespace coreutils {
///
/// HTTPRequest parses a full HTTP/1.1 request - request line, headers,
/// and (for a Content-Length body) either a raw body or, for
/// application/x-www-form-urlencoded / multipart/form-data, a parsed
/// CGIFormData - off a shared StreamReader. Resumable across any number
/// of StreamReader::feed() calls, exactly like every other Parser in
/// this subsystem: built by chaining LineParser (request line) and
/// IMFHeader (headers) via checkParse(), the same way CGIFormData itself
/// chains IMFHeader/IMFBoundary over one shared StreamReader.
///
/// This codebase predates ever parsing raw HTTP off a socket (it has
/// only ever sat behind a CGI gateway) - IMFRequest/IMFResponse/
/// IMFMessage are stubs from before this Parser/StreamReader subsystem
/// existed and are not built on here.
///
/// v1 limitations, deliberately not addressed (flagged, not fixed -
/// matching the standard already set by other Parser subclasses here for
/// known-but-out-of-scope gaps):
/// - chunked Transfer-Encoding is not supported, only Content-Length.
/// - repeated header names (e.g. multiple Cookie: lines) last-wins -
/// the same limitation IMFHeader's std::map already has, inherited
/// here since HEADERS delegates to IMFHeader directly.
///
class HTTPRequest : public Parser {
public:
HTTPRequest(StreamReader &reader);
virtual ~HTTPRequest();
///
/// Valid once doParse() has returned Status::COMPLETE.
///
MString getMethod();
MString getPath();
MString getQueryString();
MString getVersion();
///
/// Case-insensitive header lookup (HTTP header names are
/// case-insensitive per RFC 7230, unlike IMFHeader's underlying
/// std::map, which compares byte-for-byte) - returns an empty
/// MString if the header wasn't present. Valid once the HEADERS
/// state has completed (i.e. from BODY parsing onward).
///
MString getHeader(const char *name);
///
/// Every header as parsed, keyed exactly as received (not
/// case-normalized) - prefer getHeader() for lookups.
///
std::map<MString, MString> &getHeaders();
///
/// Valid once doParse() reports COMPLETE. Populated only when the
/// request had a body that was neither application/
/// x-www-form-urlencoded nor multipart/form-data (e.g. a raw JSON
/// body) - empty otherwise, including when there was no body at all.
///
MString getBody();
///
/// Valid once doParse() reports COMPLETE, when the body's
/// Content-Type was application/x-www-form-urlencoded or
/// multipart/form-data - NULL otherwise (including no body at all).
/// Owned by this HTTPRequest - do not delete.
///
CGIFormData *getFormData();
protected:
Status parse() override;
private:
enum State { REQUEST_LINE, HEADERS, BODY, DONE } state = REQUEST_LINE;
MString method, path, queryString, version;
std::map<MString, MString> headers;
MString body;
MString boundary;
LineParser *lineParser = NULL;
IMFHeader *headerParser = NULL;
CGIFormData *formParser = NULL;
};
}
#endif
+53
View File
@@ -0,0 +1,53 @@
#include "HTTPResponse.h"
#include <strings.h>
namespace coreutils {
HTTPResponse::HTTPResponse() {
statusText = "OK";
}
void HTTPResponse::setStatus(int code, ZString text) {
statusCode = code;
statusText = text;
}
void HTTPResponse::setHeader(ZString name, ZString value) {
headers[MString(name)] = MString(value);
}
void HTTPResponse::setBody(ZString bodyIn) {
body = bodyIn;
}
MString HTTPResponse::serialize() {
bool hasContentLength = false;
for (auto &pair : headers) {
MString key = pair.first;
if (strcasecmp(key.c_str(), "Content-Length") == 0)
hasContentLength = true;
}
MString out;
out << "HTTP/1.1 ";
out << MString(std::to_string(statusCode));
out << " ";
out << statusText;
out << "\r\n";
for (auto &pair : headers) {
out << pair.first;
out << ": ";
out << pair.second;
out << "\r\n";
}
if (!hasContentLength) {
out << "Content-Length: ";
out << MString(std::to_string(body.getLength()));
out << "\r\n";
}
out << "\r\n";
out << body;
return out;
}
}
+51
View File
@@ -0,0 +1,51 @@
#ifndef __HTTPResponse_h__
#define __HTTPResponse_h__
#include "MString.h"
#include <map>
namespace coreutils {
///
/// HTTPResponse builds a raw HTTP/1.1 response (status line + headers +
/// body) ready to write to a socket. Output-only, not a Parser - there's
/// nothing to resume here, the whole thing is built and serialized in
/// one pass once a handler has finished producing its body.
///
/// Deliberately new rather than built on/patched from IMFResponse -
/// IMFResponse's own header-emission code is dead (calls addHeader()/
/// output(), neither of which exist on that class) and it never grew a
/// real headers container, so there's nothing there safe to extend.
///
class HTTPResponse {
public:
HTTPResponse();
void setStatus(int code, ZString text);
void setHeader(ZString name, ZString value);
void setBody(ZString body);
///
/// Serializes the full response - status line, headers (an explicit
/// Content-Length header is respected as-is; otherwise one is
/// computed from the body automatically), the blank line, and the
/// body - ready to write to a socket.
///
MString serialize();
private:
int statusCode = 200;
MString statusText;
std::map<MString, MString> headers;
MString body;
};
}
#endif
Executable → Regular
+1
View File
@@ -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++ -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 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++20 -o jfile_test jfile_test.cpp -I.. -L.. -lCoreUtils
g++ -g -std=c++23 -o http_test http_test.cpp -I.. -L.. -lCoreUtils
+179
View File
@@ -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;
}