Compare commits

...

9 Commits
v1.0.0 ... main

Author SHA1 Message Date
Andrew Lalis 905e3f93f8 Upgrade primitives to 1.6
Build and Test Module / build-and-test (push) Successful in 12s Details
2025-03-26 14:34:41 -04:00
Andrew Lalis c2cd2cfc5c upgraded dependencies
Build and Test Module / build-and-test (push) Successful in 11s Details
2025-03-23 20:03:55 -04:00
Andrew Lalis 4709f8b00c updated dependency version strings, and added test for response output stream.
Build and Test Module / build-and-test (push) Successful in 12s Details
2025-03-13 19:42:31 -04:00
Andrew Lalis b06cb7547f Added more tests.
Build and Test Module / build-and-test (push) Successful in 13s Details
2025-03-06 21:55:04 -05:00
Andrew Lalis 4d352a0ffa Upgraded dependencies, and simplified response output stream.
Build and Test Module / build-and-test (push) Successful in 11s Details
2025-03-05 20:13:45 -05:00
Andrew Lalis b260ddfe8c Added public imports for packages. 2025-01-14 17:57:01 -05:00
Andrew Lalis 027ad4c1e6 Added some documentation to readme, and url decoding 2025-01-13 18:10:43 -05:00
Andrew Lalis f20c027e1c Added address 2025-01-13 14:23:18 -05:00
Andrew Lalis 5d42fa7f83 Cleaned up main http1.1 module. 2025-01-09 22:26:09 -05:00
10 changed files with 423 additions and 185 deletions

19
.gitea/workflows/ci.yaml Normal file
View File

@ -0,0 +1,19 @@
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

View File

@ -1,6 +1,12 @@
# http-transport
Implementations of HTTP transport protocols, compatible with other Handy-Http components.
This library provides implementations of various versions of HTTP transport,
acting as a "glue" for connecting clients and servers. Practically speaking,
the handy-http-transport library provides HTTP server implementations you can
use interchangeably with other handy-http libraries.
For now, see the section on HTTP/1.1, as that's the only HTTP version
implemented so far.
## HTTP/1.1

View File

@ -4,9 +4,9 @@
],
"copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": {
"handy-http-primitives": "~>1.0.0",
"photon": "~>0.10.2",
"streams": "~>3.5.0"
"handy-http-primitives": "~>1.6",
"photon": "~>0.10",
"streams": "~>3.6"
},
"description": "Implementations of HTTP transport protocols.",
"license": "CC0",

View File

@ -1,9 +1,9 @@
{
"fileVersion": 1,
"versions": {
"handy-http-primitives": "1.0.0",
"handy-http-primitives": "1.6.0",
"photon": "0.10.2",
"sharded-map": "2.7.0",
"streams": "3.5.0"
"streams": "3.6.0"
}
}

View File

@ -0,0 +1,104 @@
module handy_http_transport.helpers;
import streams;
/**
* Helper function to consume string content from an input stream until a
* certain target pattern of characters is encountered.
* Params:
* inputStream = The stream to read from.
* target = The target at which to stop reading.
* Returns: The string that was read, or a stream error.
*/
Either!(string, "value", StreamError, "error") consumeUntil(S)(
ref S inputStream,
string target
) if (isByteInputStream!S) {
ubyte[1024] buffer;
size_t idx;
while (true) {
auto result = inputStream.readFromStream(buffer[idx .. idx + 1]);
if (result.hasError) return Either!(string, "value", StreamError, "error")(result.error);
if (result.count != 1) return Either!(string, "value", StreamError, "error")(
StreamError("Failed to read a single element", 1)
);
idx++;
if (idx >= target.length && buffer[idx - target.length .. idx] == target) {
return Either!(string, "value", StreamError, "error")(
cast(string) buffer[0 .. idx - target.length].idup
);
}
if (idx >= buffer.length) {
return Either!(string, "value", StreamError, "error")(
StreamError("Couldn't find target \"" ~ target ~ "\" after reading 1024 bytes.", 1)
);
}
}
}
/**
* Internal helper function to get the first index of a character in a string.
* Params:
* s = The string to look in.
* c = The character to look for.
* offset = An optional offset to look from.
* Returns: The index of the character, or -1.
*/
ptrdiff_t indexOf(string s, char c, size_t offset = 0) {
for (size_t i = offset; i < s.length; i++) {
if (s[i] == c) return i;
}
return -1;
}
/**
* Internal helper function that returns the slice of a string excluding any
* preceding or trailing spaces.
* Params:
* s = The string to strip.
* Returns: The slice of the string that has been stripped.
*/
string stripSpaces(string s) {
if (s.length == 0) return s;
ptrdiff_t startIdx = 0;
while (startIdx < s.length && s[startIdx] == ' ') startIdx++;
s = s[startIdx .. $];
if (s.length == 0) return "";
ptrdiff_t endIdx = s.length - 1;
while (s[endIdx] == ' ' && endIdx >= 0) endIdx--;
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
* assumed that there's enough space to write the value.
* Params:
* value = The value to append.
* buffer = The buffer to append to.
* idx = A reference to a variable tracking the next writable index in the buffer.
*/
void writeUIntToBuffer(uint value, char[] buffer, ref size_t idx) {
const size_t startIdx = idx;
while (true) {
ubyte remainder = value % 10;
value /= 10;
buffer[idx++] = cast(char) ('0' + remainder);
if (value == 0) break;
}
// Swap the characters to proper order.
for (size_t i = 0; i < (idx - startIdx) / 2; i++) {
size_t p1 = i + startIdx;
size_t p2 = idx - i - 1;
char tmp = buffer[p1];
buffer[p1] = buffer[p2];
buffer[p2] = tmp;
}
}

View File

@ -3,3 +3,4 @@
*/
module handy_http_transport.http1;
public import handy_http_transport.http1.transport;

View File

@ -3,7 +3,11 @@ module handy_http_transport.http1.transport;
import std.socket;
import handy_http_transport.interfaces;
import handy_http_transport.helpers;
import handy_http_transport.response_output_stream;
import handy_http_primitives;
import handy_http_primitives.address;
import streams;
import photon;
@ -66,7 +70,10 @@ class Http1Transport : HttpTransport {
void handleClient(Socket clientSocket, HttpRequestHandler requestHandler) {
auto inputStream = SocketInputStream(clientSocket);
auto bufferedInput = bufferedInputStreamFor!(8192)(inputStream);
auto result = readHttpRequest(&bufferedInput);
// Get remote address from the socket.
import handy_http_primitives.address;
ClientAddress addr = getAddress(clientSocket);
auto result = readHttpRequest(&bufferedInput, addr);
if (result.hasError) {
import std.stdio;
stderr.writeln("Failed to read HTTP request: " ~ result.error.message);
@ -85,8 +92,81 @@ void handleClient(Socket clientSocket, HttpRequestHandler requestHandler) {
} catch (Exception e) {
import std.stdio;
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();
}
}
// 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());
}
/**
* Gets a ClientAddress value from a socket's address information.
* Params:
* socket = The socket to get address information for.
* Returns: The address that was obtained.
*/
ClientAddress getAddress(Socket socket) {
try {
Address addr = socket.remoteAddress();
if (auto a = cast(InternetAddress) addr) {
union U {
ubyte[4] bytes;
uint intValue;
}
U u;
u.intValue = a.addr();
return ClientAddress.ofIPv4(IPv4InternetAddress(
u.bytes,
a.port()
));
} else if (auto a = cast(Internet6Address) addr) {
return ClientAddress.ofIPv6(IPv6InternetAddress(
a.addr(),
a.port()
));
} else if (auto a = cast(UnixAddress) addr) {
return ClientAddress.ofUnixSocket(UnixSocketAddress(a.path()));
} else {
return ClientAddress(ClientAddressType.UNKNOWN);
}
} catch (SocketOSException e) {
return ClientAddress(ClientAddressType.UNKNOWN);
}
inputStream.closeStream();
}
/// Alias for the result of the `readHttpRequest` function which parses HTTP requests.
@ -96,11 +176,10 @@ alias HttpRequestParseResult = Either!(ServerHttpRequest, "request", StreamError
* Parses an HTTP/1.1 request from a byte input stream.
* Params:
* inputStream = The byte input stream to read from.
* addr = The client address, used in constructed the http request struct.
* Returns: Either the request which was parsed, or a stream error.
*/
HttpRequestParseResult readHttpRequest(S)(S inputStream) if (isByteInputStream!S) {
import handy_http_primitives.address;
HttpRequestParseResult readHttpRequest(S)(S inputStream, in ClientAddress addr) if (isByteInputStream!S) {
auto methodStr = consumeUntil(inputStream, " ");
if (methodStr.hasError) return HttpRequestParseResult(methodStr.error);
auto urlStr = consumeUntil(inputStream, " ");
@ -118,16 +197,60 @@ HttpRequestParseResult readHttpRequest(S)(S inputStream) if (isByteInputStream!S
auto headersResult = parseHeaders(inputStream);
if (headersResult.hasError) return HttpRequestParseResult(headersResult.error);
auto queryParams = parseQueryParameters(urlStr.value);
import std.uri : decode; // TODO: Remove dependency on phobos for this?
return HttpRequestParseResult(ServerHttpRequest(
httpVersion,
ClientAddress.init, // TODO: Get this from the socket, if possible?
addr,
methodStr.value,
urlStr.value,
decode(urlStr.value),
headersResult.headers,
queryParams,
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
* array mapping header names to their list of values.
@ -158,179 +281,36 @@ Either!(string[][string], "headers", StreamError, "error") parseHeaders(S)(S inp
return Either!(string[][string], "headers", StreamError, "error")(headers);
}
/**
* Helper function to consume string content from an input stream until a
* certain target pattern of characters is encountered.
* Params:
* inputStream = The stream to read from.
* target = The target at which to stop reading.
* Returns: The string that was read, or a stream error.
*/
private Either!(string, "value", StreamError, "error") consumeUntil(S)(
S inputStream,
string target
) if (isByteInputStream!S) {
ubyte[1024] buffer;
size_t idx;
while (true) {
auto result = inputStream.readFromStream(buffer[idx .. idx + 1]);
if (result.hasError) return Either!(string, "value", StreamError, "error")(result.error);
if (result.count != 1) return Either!(string, "value", StreamError, "error")(
StreamError("Failed to read a single element", 1)
);
idx++;
if (idx >= target.length && buffer[idx - target.length .. idx] == target) {
return Either!(string, "value", StreamError, "error")(
cast(string) buffer[0 .. idx - target.length].idup
);
}
if (idx >= buffer.length) {
return Either!(string, "value", StreamError, "error")(
StreamError("Couldn't find target \"" ~ target ~ "\" after reading 1024 bytes.", 1)
);
}
}
}
/**
* Internal helper function to get the first index of a character in a string.
* Params:
* s = The string to look in.
* c = The character to look for.
* offset = An optional offset to look from.
* Returns: The index of the character, or -1.
*/
private ptrdiff_t indexOf(string s, char c, size_t offset = 0) {
for (size_t i = offset; i < s.length; i++) {
if (s[i] == c) return i;
}
return -1;
}
/**
* Internal helper function that returns the slice of a string excluding any
* preceding or trailing spaces.
* Params:
* s = The string to strip.
* Returns: The slice of the string that has been stripped.
*/
private string stripSpaces(string s) {
if (s.length == 0) return s;
ptrdiff_t startIdx = 0;
while (s[startIdx] == ' ' && startIdx < s.length) startIdx++;
s = s[startIdx .. $];
ptrdiff_t endIdx = s.length - 1;
while (s[endIdx] == ' ' && endIdx >= 0) endIdx--;
return s[0 .. endIdx + 1];
}
/**
* Helper function to append an unsigned integer value to a char buffer. It is
* assumed that there's enough space to write the value.
* Params:
* value = The value to append.
* buffer = The buffer to append to.
* idx = A reference to a variable tracking the next writable index in the buffer.
*/
private void writeUIntToBuffer(uint value, char[] buffer, ref size_t idx) {
const size_t startIdx = idx;
while (true) {
ubyte remainder = value % 10;
value /= 10;
buffer[idx++] = cast(char) ('0' + remainder);
if (value == 0) break;
}
// Swap the characters to proper order.
for (size_t i = 0; i < (idx - startIdx) / 2; i++) {
size_t p1 = i + startIdx;
size_t p2 = idx - i - 1;
char tmp = buffer[p1];
buffer[p1] = buffer[p2];
buffer[p2] = tmp;
}
}
/**
* A wrapper around a byte output stream that's used for writing HTTP response
* content. It keeps a reference to the `ServerHttpResponse` so that when a
* handler writes data to the stream, it'll flush the HTTP response status and
* headers beforehand.
*/
struct HttpResponseOutputStream(S) if (isByteOutputStream!S) {
/// The underlying output stream to write to.
private S outputStream;
/// A pointer to the HTTP response that this stream is for.
private ServerHttpResponse* response;
/// Flag that keeps track of if the HTTP status and headers were written.
private bool headersFlushed = false;
this(S outputStream, ServerHttpResponse* response) {
this.outputStream = outputStream;
this.response = response;
}
/**
* Writes the given data to the stream. If the referenced HTTP response's
* status and headers haven't yet been written, they will be written first.
* Params:
* buffer = The buffer containing data to write.
* Returns: The result of writing. If status and headers are written, the
* number of bytes written will include that in addition to the buffer size.
*/
StreamResult writeToStream(ubyte[] buffer) {
uint bytesWritten = 0;
if (!headersFlushed) {
auto result = writeHeaders();
if (result.hasError) return result;
bytesWritten += result.count;
headersFlushed = true;
}
auto result = outputStream.writeToStream(buffer);
if (result.hasError) return result;
return StreamResult(result.count + bytesWritten);
}
/**
* Writes HTTP/1.1 status line and headers to the underlying output stream,
* which is done before any body content can be written.
* Returns: The stream result of writing.
*/
StreamResult writeHeaders() {
// TODO: Come up with a better way of writing headers than string concatenation.
size_t idx = 0;
char[6] statusCodeBuffer; // Normal HTTP codes are 3 digits, but this leaves room for extensions.
writeUIntToBuffer(response.status.code, statusCodeBuffer, idx);
string statusAndHeaders = "HTTP/1.1 "
~ cast(string) statusCodeBuffer[0..idx]
~ " " ~ response.status.text
~ "\r\n";
foreach (headerName; response.headers.keys) {
string headerLine = headerName ~ ": ";
string[] headerValues = response.headers.getAll(headerName);
for (size_t i = 0; i < headerValues.length; i++) {
headerLine ~= headerValues[i];
if (i + 1 < headerValues.length) {
headerLine ~= ", ";
}
}
headerLine ~= "\r\n";
statusAndHeaders ~= headerLine;
}
statusAndHeaders ~= "\r\n"; // Trailing CLRF before the body.
return outputStream.writeToStream(cast(ubyte[]) statusAndHeaders);
}
}
unittest {
class TestHandler : HttpRequestHandler {
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
response.status = HttpStatus.OK;
response.headers.add("Content-Type", "application/json");
response.outputStream.writeToStream(cast(ubyte[]) "{\"a\": 1}");
}
import streams;
auto makeStream(string text) {
return arrayInputStreamFor(cast(ubyte[]) text);
}
HttpTransport tp = new Http1Transport(new TestHandler(), 8080);
tp.start();
// Basic valid headers.
auto r1 = parseHeaders(makeStream("Content-Type: application/json\r\n\r\n"));
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);
}

View File

@ -1,2 +1,3 @@
module handy_http_transport.http2;
// Not yet implemented.

View File

@ -2,3 +2,6 @@ module handy_http_transport;
public import handy_http_transport.http1;
public import handy_http_transport.http2;
public import handy_http_transport.interfaces;
public import handy_http_transport.response_output_stream;

View File

@ -0,0 +1,124 @@
module handy_http_transport.response_output_stream;
import handy_http_transport.helpers : writeUIntToBuffer;
import handy_http_primitives : ServerHttpResponse;
import streams;
/**
* A wrapper around a byte output stream that's used for writing HTTP response
* content. It keeps a reference to the `ServerHttpResponse` so that when a
* handler writes data to the stream, it'll flush the HTTP response status and
* headers beforehand.
*/
struct HttpResponseOutputStream(S) if (isByteOutputStream!S) {
/// The underlying output stream to write to.
private S outputStream;
/// A pointer to the HTTP response that this stream is for.
private ServerHttpResponse* response;
/// Flag that keeps track of if the HTTP status and headers were written.
private bool headersFlushed = false;
this(S outputStream, ServerHttpResponse* response) {
this.outputStream = outputStream;
this.response = response;
}
/**
* Writes the given data to the stream. If the referenced HTTP response's
* status and headers haven't yet been written, they will be written first.
* Params:
* buffer = The buffer containing data to write.
* Returns: The result of writing. If status and headers are written, the
* number of bytes written will include that in addition to the buffer size.
*/
StreamResult writeToStream(ubyte[] buffer) {
uint bytesWritten = 0;
if (!headersFlushed) {
auto result = writeHeaders();
if (result.hasError) return result;
bytesWritten += result.count;
headersFlushed = true;
}
auto result = outputStream.writeToStream(buffer);
if (result.hasError) return result;
return StreamResult(result.count + bytesWritten);
}
/**
* Writes HTTP/1.1 status line and headers to the underlying output stream,
* which is done before any body content can be written.
* Returns: The stream result of writing.
*/
StreamResult writeHeaders() {
size_t idx = 0;
char[6] statusCodeBuffer; // Normal HTTP codes are 3 digits, but this leaves room for extensions.
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;
foreach (headerName; response.headers.keys) {
// Write the header name.
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);
for (size_t i = 0; i < headerValues.length; i++) {
r = outputStream.writeToStream(cast(ubyte[]) headerValues[i]);
if (r.hasError) return r;
writeCount += r.count;
if (i + 1 < headerValues.length) {
r = outputStream.writeToStream([',', ' ']);
if (r.hasError) return r;
writeCount += r.count;
}
}
r = outputStream.writeToStream(['\r', '\n']);
if (r.hasError) return r;
writeCount += r.count;
}
r = outputStream.writeToStream(['\r', '\n']); // Trailing CLRF before the body.
if (r.hasError) return r;
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);
}