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
@@ -9,3 +9,4 @@ build/
|
|||||||
.history/
|
.history/
|
||||||
testing/mstring_test
|
testing/mstring_test
|
||||||
testing/zstring_test
|
testing/zstring_test
|
||||||
|
testing/http_test
|
||||||
+127
@@ -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
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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