Compare commits
No commits in common. "main" and "v1.0.2" have entirely different histories.
|
@ -1,19 +0,0 @@
|
||||||
name: Build and Test Module
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'source/**'
|
|
||||||
- '.gitea/workflows/ci.yaml'
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
jobs:
|
|
||||||
build-and-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup DLang
|
|
||||||
uses: dlang-community/setup-dlang@v2
|
|
||||||
with:
|
|
||||||
compiler: ldc-latest
|
|
||||||
- name: Build and Test
|
|
||||||
run: dub -q test
|
|
6
dub.json
6
dub.json
|
@ -4,9 +4,9 @@
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"handy-http-primitives": "~>1.6",
|
"handy-http-primitives": "~>1.0.0",
|
||||||
"photon": "~>0.10",
|
"photon": "~>0.10.2",
|
||||||
"streams": "~>3.6"
|
"streams": "~>3.5.0"
|
||||||
},
|
},
|
||||||
"description": "Implementations of HTTP transport protocols.",
|
"description": "Implementations of HTTP transport protocols.",
|
||||||
"license": "CC0",
|
"license": "CC0",
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"handy-http-primitives": "1.6.0",
|
"handy-http-primitives": "1.0.0",
|
||||||
"photon": "0.10.2",
|
"photon": "0.10.2",
|
||||||
"sharded-map": "2.7.0",
|
"sharded-map": "2.7.0",
|
||||||
"streams": "3.6.0"
|
"streams": "3.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import streams;
|
||||||
* Returns: The string that was read, or a stream error.
|
* Returns: The string that was read, or a stream error.
|
||||||
*/
|
*/
|
||||||
Either!(string, "value", StreamError, "error") consumeUntil(S)(
|
Either!(string, "value", StreamError, "error") consumeUntil(S)(
|
||||||
ref S inputStream,
|
S inputStream,
|
||||||
string target
|
string target
|
||||||
) if (isByteInputStream!S) {
|
) if (isByteInputStream!S) {
|
||||||
ubyte[1024] buffer;
|
ubyte[1024] buffer;
|
||||||
|
@ -61,22 +61,13 @@ ptrdiff_t indexOf(string s, char c, size_t offset = 0) {
|
||||||
string stripSpaces(string s) {
|
string stripSpaces(string s) {
|
||||||
if (s.length == 0) return s;
|
if (s.length == 0) return s;
|
||||||
ptrdiff_t startIdx = 0;
|
ptrdiff_t startIdx = 0;
|
||||||
while (startIdx < s.length && s[startIdx] == ' ') startIdx++;
|
while (s[startIdx] == ' ' && startIdx < s.length) startIdx++;
|
||||||
s = s[startIdx .. $];
|
s = s[startIdx .. $];
|
||||||
if (s.length == 0) return "";
|
|
||||||
ptrdiff_t endIdx = s.length - 1;
|
ptrdiff_t endIdx = s.length - 1;
|
||||||
while (s[endIdx] == ' ' && endIdx >= 0) endIdx--;
|
while (s[endIdx] == ' ' && endIdx >= 0) endIdx--;
|
||||||
return s[0 .. endIdx + 1];
|
return s[0 .. endIdx + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
unittest {
|
|
||||||
assert(stripSpaces("") == "");
|
|
||||||
assert(stripSpaces(" ") == "");
|
|
||||||
assert(stripSpaces("test") == "test");
|
|
||||||
assert(stripSpaces(" test") == "test");
|
|
||||||
assert(stripSpaces(" test string ") == "test string");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to append an unsigned integer value to a char buffer. It is
|
* Helper function to append an unsigned integer value to a char buffer. It is
|
||||||
* assumed that there's enough space to write the value.
|
* assumed that there's enough space to write the value.
|
||||||
|
|
|
@ -92,46 +92,8 @@ void handleClient(Socket clientSocket, HttpRequestHandler requestHandler) {
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
stderr.writeln("Exception thrown while handling request: " ~ e.msg);
|
stderr.writeln("Exception thrown while handling request: " ~ e.msg);
|
||||||
} catch (Throwable t) {
|
|
||||||
import std.stdio;
|
|
||||||
stderr.writeln("Throwable error while handling request: " ~ t.msg);
|
|
||||||
throw t;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status != HttpStatus.SWITCHING_PROTOCOLS) {
|
|
||||||
inputStream.closeStream();
|
inputStream.closeStream();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test case where we use a local socket pair to test the full handleClient
|
|
||||||
// workflow from the HttpRequestHandler's point of view.
|
|
||||||
unittest {
|
|
||||||
Socket[2] sockets = socketPair();
|
|
||||||
Socket clientSocket = sockets[0];
|
|
||||||
Socket serverSocket = sockets[1];
|
|
||||||
const requestContent =
|
|
||||||
"POST /data HTTP/1.1\r\n" ~
|
|
||||||
"Content-Type: application/json\r\n" ~
|
|
||||||
"Content-Length: 22\r\n" ~
|
|
||||||
"\r\n" ~
|
|
||||||
"{\"x\": 5, \"flag\": true}";
|
|
||||||
clientSocket.send(cast(ubyte[]) requestContent);
|
|
||||||
|
|
||||||
class TestHandler : HttpRequestHandler {
|
|
||||||
import std.conv;
|
|
||||||
|
|
||||||
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
|
||||||
assert(request.headers["Content-Type"] == ["application/json"]);
|
|
||||||
assert("Content-Length" in request.headers && request.headers["Content-Length"].length > 0);
|
|
||||||
ulong contentLength = request.headers["Content-Length"][0].to!ulong;
|
|
||||||
assert(contentLength == 22);
|
|
||||||
ubyte[22] bodyBuffer;
|
|
||||||
auto readResult = request.inputStream.readFromStream(bodyBuffer);
|
|
||||||
assert(readResult.hasCount && readResult.count == 22);
|
|
||||||
assert(cast(string) bodyBuffer == "{\"x\": 5, \"flag\": true}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleClient(serverSocket, new TestHandler());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -197,8 +159,6 @@ HttpRequestParseResult readHttpRequest(S)(S inputStream, in ClientAddress addr)
|
||||||
auto headersResult = parseHeaders(inputStream);
|
auto headersResult = parseHeaders(inputStream);
|
||||||
if (headersResult.hasError) return HttpRequestParseResult(headersResult.error);
|
if (headersResult.hasError) return HttpRequestParseResult(headersResult.error);
|
||||||
|
|
||||||
auto queryParams = parseQueryParameters(urlStr.value);
|
|
||||||
|
|
||||||
import std.uri : decode; // TODO: Remove dependency on phobos for this?
|
import std.uri : decode; // TODO: Remove dependency on phobos for this?
|
||||||
|
|
||||||
return HttpRequestParseResult(ServerHttpRequest(
|
return HttpRequestParseResult(ServerHttpRequest(
|
||||||
|
@ -207,50 +167,10 @@ HttpRequestParseResult readHttpRequest(S)(S inputStream, in ClientAddress addr)
|
||||||
methodStr.value,
|
methodStr.value,
|
||||||
decode(urlStr.value),
|
decode(urlStr.value),
|
||||||
headersResult.headers,
|
headersResult.headers,
|
||||||
queryParams,
|
|
||||||
inputStreamObjectFor(inputStream)
|
inputStreamObjectFor(inputStream)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
unittest {
|
|
||||||
import streams;
|
|
||||||
|
|
||||||
auto makeStream(string text) {
|
|
||||||
return arrayInputStreamFor(cast(ubyte[]) text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic HTTP request.
|
|
||||||
ArrayInputStream!ubyte s1 = makeStream(
|
|
||||||
"GET /test?x=5 HTTP/1.1\r\n" ~
|
|
||||||
"Accept: text/plain\r\n" ~
|
|
||||||
"\r\n"
|
|
||||||
);
|
|
||||||
auto r1 = readHttpRequest(&s1, ClientAddress.unknown());
|
|
||||||
assert(r1.hasRequest);
|
|
||||||
assert(r1.request.httpVersion == HttpVersion.V1);
|
|
||||||
assert(r1.request.method == HttpMethod.GET);
|
|
||||||
assert(r1.request.url == "/test?x=5");
|
|
||||||
const r1ExpectedHeaders = ["Accept": ["text/plain"]];
|
|
||||||
assert(r1.request.headers == r1ExpectedHeaders);
|
|
||||||
assert(r1.request.clientAddress == ClientAddress.unknown());
|
|
||||||
|
|
||||||
// POST request with body. Test that the body is read correctly.
|
|
||||||
ArrayInputStream!ubyte s2 = makeStream(
|
|
||||||
"POST /data HTTP/1.1\r\n" ~
|
|
||||||
"Content-Type: text/plain\r\n" ~
|
|
||||||
"Content-Length: 12\r\n" ~
|
|
||||||
"\r\n" ~
|
|
||||||
"Hello world!"
|
|
||||||
);
|
|
||||||
auto r2 = readHttpRequest(&s2, ClientAddress.unknown());
|
|
||||||
assert(r2.hasRequest);
|
|
||||||
assert(r2.request.method == HttpMethod.POST);
|
|
||||||
ubyte[12] r2BodyBuffer;
|
|
||||||
StreamResult r2BodyReadResult = s2.readFromStream(r2BodyBuffer);
|
|
||||||
assert(r2BodyReadResult.count == 12);
|
|
||||||
assert(cast(string) r2BodyBuffer == "Hello world!");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses HTTP headers from an input stream, and returns them as an associative
|
* Parses HTTP headers from an input stream, and returns them as an associative
|
||||||
* array mapping header names to their list of values.
|
* array mapping header names to their list of values.
|
||||||
|
@ -282,35 +202,14 @@ Either!(string[][string], "headers", StreamError, "error") parseHeaders(S)(S inp
|
||||||
}
|
}
|
||||||
|
|
||||||
unittest {
|
unittest {
|
||||||
import streams;
|
class TestHandler : HttpRequestHandler {
|
||||||
|
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
auto makeStream(string text) {
|
response.status = HttpStatus.OK;
|
||||||
return arrayInputStreamFor(cast(ubyte[]) text);
|
response.headers.add("Content-Type", "application/json");
|
||||||
|
response.outputStream.writeToStream(cast(ubyte[]) "{\"a\": 1}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic valid headers.
|
HttpTransport tp = new Http1Transport(new TestHandler(), 8080);
|
||||||
auto r1 = parseHeaders(makeStream("Content-Type: application/json\r\n\r\n"));
|
tp.start();
|
||||||
assert(r1.hasHeaders);
|
|
||||||
assert("Content-Type" in r1.headers);
|
|
||||||
assert(r1.headers["Content-Type"] == ["application/json"]);
|
|
||||||
|
|
||||||
// Multiple headers.
|
|
||||||
auto r2 = parseHeaders(makeStream("Accept: text, json, image\r\nContent-Length: 1234\r\n\r\n"));
|
|
||||||
assert(r2.hasHeaders);
|
|
||||||
assert("Accept" in r2.headers);
|
|
||||||
assert(r2.headers["Accept"] == ["text, json, image"]);
|
|
||||||
assert(r2.headers["Content-Length"] == ["1234"]);
|
|
||||||
|
|
||||||
// Basic invalid header string.
|
|
||||||
auto r3 = parseHeaders(makeStream("Invalid headers"));
|
|
||||||
assert(r3.hasError);
|
|
||||||
|
|
||||||
// No trailing \r\n
|
|
||||||
auto r4 = parseHeaders(makeStream("Content-Type: application/json"));
|
|
||||||
assert(r4.hasError);
|
|
||||||
|
|
||||||
// Empty headers.
|
|
||||||
auto r5 = parseHeaders(makeStream("\r\n"));
|
|
||||||
assert(r5.hasHeaders);
|
|
||||||
assert(r5.headers.length == 0);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,75 +50,28 @@ struct HttpResponseOutputStream(S) if (isByteOutputStream!S) {
|
||||||
* Returns: The stream result of writing.
|
* Returns: The stream result of writing.
|
||||||
*/
|
*/
|
||||||
StreamResult writeHeaders() {
|
StreamResult writeHeaders() {
|
||||||
|
// TODO: Come up with a better way of writing headers than string concatenation.
|
||||||
size_t idx = 0;
|
size_t idx = 0;
|
||||||
char[6] statusCodeBuffer; // Normal HTTP codes are 3 digits, but this leaves room for extensions.
|
char[6] statusCodeBuffer; // Normal HTTP codes are 3 digits, but this leaves room for extensions.
|
||||||
writeUIntToBuffer(response.status.code, statusCodeBuffer, idx);
|
writeUIntToBuffer(response.status.code, statusCodeBuffer, idx);
|
||||||
// Write the status line.
|
|
||||||
StreamResult r = outputStream.writeToStream(cast(ubyte[]) "HTTP/1.1 ");
|
|
||||||
if (r.hasError) return r;
|
|
||||||
size_t writeCount = r.count;
|
|
||||||
r = outputStream.writeToStream(cast(ubyte[]) statusCodeBuffer[0..idx]);
|
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
r = outputStream.writeToStream([' ']);
|
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
r = outputStream.writeToStream(cast(ubyte[]) response.status.text);
|
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
r = outputStream.writeToStream(['\r', '\n']);
|
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
|
|
||||||
|
string statusAndHeaders = "HTTP/1.1 "
|
||||||
|
~ cast(string) statusCodeBuffer[0..idx]
|
||||||
|
~ " " ~ response.status.text
|
||||||
|
~ "\r\n";
|
||||||
foreach (headerName; response.headers.keys) {
|
foreach (headerName; response.headers.keys) {
|
||||||
// Write the header name.
|
string headerLine = headerName ~ ": ";
|
||||||
r = outputStream.writeToStream(cast(ubyte[]) headerName);
|
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
r = outputStream.writeToStream([':', ' ']);
|
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
// Write the comma-separated list of values.
|
|
||||||
string[] headerValues = response.headers.getAll(headerName);
|
string[] headerValues = response.headers.getAll(headerName);
|
||||||
for (size_t i = 0; i < headerValues.length; i++) {
|
for (size_t i = 0; i < headerValues.length; i++) {
|
||||||
r = outputStream.writeToStream(cast(ubyte[]) headerValues[i]);
|
headerLine ~= headerValues[i];
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
if (i + 1 < headerValues.length) {
|
if (i + 1 < headerValues.length) {
|
||||||
r = outputStream.writeToStream([',', ' ']);
|
headerLine ~= ", ";
|
||||||
if (r.hasError) return r;
|
|
||||||
writeCount += r.count;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r = outputStream.writeToStream(['\r', '\n']);
|
headerLine ~= "\r\n";
|
||||||
if (r.hasError) return r;
|
statusAndHeaders ~= headerLine;
|
||||||
writeCount += r.count;
|
|
||||||
}
|
}
|
||||||
r = outputStream.writeToStream(['\r', '\n']); // Trailing CLRF before the body.
|
statusAndHeaders ~= "\r\n"; // Trailing CLRF before the body.
|
||||||
if (r.hasError) return r;
|
return outputStream.writeToStream(cast(ubyte[]) statusAndHeaders);
|
||||||
writeCount += r.count;
|
|
||||||
return StreamResult(cast(uint) writeCount);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test basic functionality for writing a standard response with headers and a
|
|
||||||
// body.
|
|
||||||
unittest {
|
|
||||||
import handy_http_primitives.response;
|
|
||||||
|
|
||||||
ArrayOutputStream!ubyte os;
|
|
||||||
ServerHttpResponse resp;
|
|
||||||
resp.status = HttpStatus.OK;
|
|
||||||
resp.headers.add("Content-Type", "text/plain");
|
|
||||||
auto httpOut = HttpResponseOutputStream!(ArrayOutputStream!ubyte*)(&os, &resp);
|
|
||||||
resp.outputStream = outputStreamObjectFor(httpOut);
|
|
||||||
StreamResult r = resp.outputStream.writeToStream(cast(ubyte[]) "Hello world!");
|
|
||||||
const expectedOutput =
|
|
||||||
"HTTP/1.1 200 OK\r\n" ~
|
|
||||||
"Content-Type: text/plain\r\n" ~
|
|
||||||
"\r\n" ~
|
|
||||||
"Hello world!";
|
|
||||||
assert(os.toArray() == expectedOutput);
|
|
||||||
assert(r.hasCount);
|
|
||||||
assert(r.count == os.toArray().length);
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue