Compare commits

...

8 Commits
v1.0.0 ... main

Author SHA1 Message Date
Andrew Lalis 799da3ff34 Added builder module, made client address base types non-const.
Build and Test Module / build-and-test (push) Successful in 6s Details
2025-03-26 14:31:18 -04:00
Andrew Lalis 79261ce20a Upgrade streams dependency. 2025-03-23 18:56:21 -04:00
Andrew Lalis a5cfdf771c Added ResponseStatusException
Build and Test Module / build-and-test (push) Successful in 5s Details
2025-03-23 12:43:09 -04:00
Andrew Lalis 0a4e507fa6 Added tests for getHeaderAs and readBody.
Build and Test Module / build-and-test (push) Successful in 12s Details
2025-03-23 12:02:26 -04:00
Andrew Lalis 88c8590fec Added request body parsing methods.
Build and Test Module / build-and-test (push) Successful in 6s Details
2025-03-09 20:51:12 -04:00
Andrew Lalis 774da2281e Added UNKNOWN client address type.
Build and Test Module / build-and-test (push) Successful in 6s Details
2025-03-05 20:00:38 -05:00
Andrew Lalis 5fbe682749 Added function and delegate constructor functions for HttpRequestHandler, and more tests.
Build and Test Module / build-and-test (push) Successful in 6s Details
2025-03-05 19:27:26 -05:00
Andrew Lalis 262947110f Added ci workflow. 2025-03-05 19:11:10 -05:00
9 changed files with 510 additions and 46 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

@ -4,7 +4,7 @@
],
"copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": {
"streams": "~>3.5.0"
"streams": "~>3.6"
},
"description": "Basic HTTP types that can be shared among various projects.",
"license": "CC0",

View File

@ -1,6 +1,6 @@
{
"fileVersion": 1,
"versions": {
"streams": "3.5.0"
"streams": "3.6.0"
}
}

View File

@ -8,8 +8,8 @@ module handy_http_primitives.address;
* that the connection was assigned to.
*/
struct IPv4InternetAddress {
const ubyte[4] bytes;
const ushort port;
ubyte[4] bytes;
ushort port;
}
/**
@ -17,8 +17,8 @@ struct IPv4InternetAddress {
* machine that the connection was assigned to.
*/
struct IPv6InternetAddress {
const ubyte[16] bytes;
const ushort port;
ubyte[16] bytes;
ushort port;
}
/**
@ -26,14 +26,15 @@ struct IPv6InternetAddress {
* IO operations take place.
*/
struct UnixSocketAddress {
const string path;
string path;
}
/// Defines the different possible address types, used by `ClientAddress`.
enum ClientAddressType {
IPv4,
IPv6,
UNIX
UNIX,
UNKNOWN
}
/**
@ -41,16 +42,17 @@ enum ClientAddressType {
* request. Use `type` to determine which information is available.
*/
struct ClientAddress {
const ClientAddressType type;
const IPv4InternetAddress ipv4InternetAddress;
const IPv6InternetAddress ipv6InternetAddress;
const UnixSocketAddress unixSocketAddress;
ClientAddressType type;
IPv4InternetAddress ipv4InternetAddress;
IPv6InternetAddress ipv6InternetAddress;
UnixSocketAddress unixSocketAddress;
/**
* Serializes this address in a human-readable string representation.
* Returns: The string representation of this address.
*/
string toString() const {
if (type == ClientAddressType.UNKNOWN) return "Unknown Address";
if (type == ClientAddressType.UNIX) return unixSocketAddress.path;
version (Posix) { import core.sys.posix.arpa.inet : inet_ntop, AF_INET, AF_INET6; }
version (Windows) { import core.sys.windows.winsock2 : inet_ntop, AF_INET, AF_INET6; }
@ -86,6 +88,15 @@ struct ClientAddress {
static ClientAddress ofUnixSocket(UnixSocketAddress addr) {
return ClientAddress(ClientAddressType.UNIX, IPv4InternetAddress.init, IPv6InternetAddress.init, addr);
}
static ClientAddress unknown() {
return ClientAddress(
ClientAddressType.UNKNOWN,
IPv4InternetAddress.init,
IPv6InternetAddress.init,
UnixSocketAddress.init
);
}
}
unittest {

View File

@ -0,0 +1,158 @@
/**
* Defines builder types to more easily construct various HTTP objects, often
* useful for testing scenarios.
*/
module handy_http_primitives.builder;
import streams;
import handy_http_primitives.request;
import handy_http_primitives.response;
import handy_http_primitives.address;
import handy_http_primitives.multivalue_map;
/**
* Fluent interface for building ServerHttpRequest objects.
*/
struct ServerHttpRequestBuilder {
HttpVersion httpVersion = HttpVersion.V1;
ClientAddress clientAddress = ClientAddress.unknown;
string method = HttpMethod.GET;
string url = "";
string[][string] headers;
QueryParameter[] queryParams;
InputStream!ubyte inputStream = inputStreamObjectFor(NoOpInputStream!ubyte());
ref withVersion(HttpVersion httpVersion) {
this.httpVersion = httpVersion;
return this;
}
ref withClientAddress(ClientAddress addr) {
this.clientAddress = addr;
return this;
}
ref withMethod(string method) {
this.method = method;
return this;
}
ref withUrl(string url) {
this.url = url;
return this;
}
ref withHeader(string headerName, string value) {
if (headerName !in this.headers) {
this.headers[headerName] = [];
}
this.headers[headerName] ~= value;
return this;
}
ref withQueryParam(string paramName, string value) {
foreach (ref param; this.queryParams) {
if (param.key == paramName) {
param.values ~= value;
return this;
}
}
this.queryParams ~= QueryParameter(paramName, [value]);
return this;
}
ref withInputStream(S)(S stream) if (isByteInputStream!S) {
this.inputStream = inputStreamObjectFor(stream);
return this;
}
ref withBody(ubyte[] bodyBytes) {
return withInputStream(arrayInputStreamFor(bodyBytes));
}
ref withBody(string bodyStr) {
return withBody(cast(ubyte[]) bodyStr);
}
ServerHttpRequest build() {
return ServerHttpRequest(
httpVersion,
clientAddress,
method,
url,
headers,
queryParams,
inputStream
);
}
}
unittest {
ServerHttpRequest r1 = ServerHttpRequestBuilder()
.withUrl("/test-url")
.withVersion(HttpVersion.V2)
.withMethod(HttpMethod.PATCH)
.withBody("Hello world!")
.withClientAddress(ClientAddress.ofUnixSocket(UnixSocketAddress("/tmp/socket")))
.withHeader("Content-Type", "text/plain")
.withHeader("Content-Length", "12")
.withQueryParam("idx", "42")
.build();
assert(r1.httpVersion == HttpVersion.V2);
assert(r1.url == "/test-url");
assert(r1.method == HttpMethod.PATCH);
string r1Body = r1.readBodyAsString();
assert(r1Body == "Hello world!");
assert(r1.clientAddress.type == ClientAddressType.UNIX);
assert(r1.clientAddress.unixSocketAddress.path == "/tmp/socket");
assert(r1.getHeaderAs!string("Content-Type") == "text/plain");
assert(r1.getHeaderAs!ulong("Content-Length") == 12);
assert(r1.getParamAs!ulong("idx") == 42);
}
/**
* Fluent interface for building ServerHttpResponse objects.
*/
struct ServerHttpResponseBuilder {
StatusInfo initialStatus = HttpStatus.OK;
StringMultiValueMap initialHeaders;
OutputStream!ubyte outputStream = outputStreamObjectFor(NoOpOutputStream!ubyte());
ref withStatus(StatusInfo status) {
this.initialStatus = status;
return this;
}
ref withHeader(string headerName, string value) {
this.initialHeaders.add(headerName, value);
return this;
}
ref withOutputStream(S)(S stream) if (isByteOutputStream!S) {
this.outputStream = outputStreamObjectFor(stream);
return this;
}
ServerHttpResponse build() {
return ServerHttpResponse(
initialStatus,
initialHeaders,
outputStream
);
}
}
unittest {
ArrayOutputStream!ubyte bufferOut = byteArrayOutputStream();
ServerHttpResponse r1 = ServerHttpResponseBuilder()
.withStatus(HttpStatus.BAD_REQUEST)
.withHeader("Test", "okay")
.withOutputStream(&bufferOut)
.build();
assert(r1.status == HttpStatus.BAD_REQUEST);
assert(r1.headers.getFirst("Test").value == "okay");
r1.outputStream.writeToStream(cast(ubyte[]) "Hello world!");
assert(bufferOut.toArray() == cast(ubyte[]) "Hello world!");
}

View File

@ -8,5 +8,40 @@ import handy_http_primitives.response;
* incoming HTTP request.
*/
interface HttpRequestHandler {
/**
* Invoked to handle an incoming HTTP request. Implementations should read
* information from the request, and write to the response.
* Params:
* request = The request that was sent by a client.
* response = The response that will be sent back to the client.
*/
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response);
/**
* Gets a request handler that invokes the given function.
* Params:
* fn = The function to invoke when handling requests.
* Returns: The request handler.
*/
static HttpRequestHandler of(void function(ref ServerHttpRequest, ref ServerHttpResponse) fn) {
return new class HttpRequestHandler {
override void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
fn(request, response);
}
};
}
/**
* Gets a request handler that invokes the given delegate.
* Params:
* dg = The delegate to invoke when handling requests.
* Returns: The request handler.
*/
static HttpRequestHandler of(void delegate(ref ServerHttpRequest, ref ServerHttpResponse) dg) {
return new class HttpRequestHandler {
override void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
dg(request, response);
}
};
}
}

View File

@ -5,3 +5,4 @@ public import handy_http_primitives.response;
public import handy_http_primitives.handler;
public import handy_http_primitives.optional;
public import handy_http_primitives.multivalue_map;
public import handy_http_primitives.builder;

View File

@ -1,6 +1,6 @@
module handy_http_primitives.request;
import streams : InputStream;
import streams;
import std.traits : EnumMembers;
import handy_http_primitives.optional;
@ -14,15 +14,247 @@ struct ServerHttpRequest {
/// The HTTP version of the request.
const HttpVersion httpVersion = HttpVersion.V1;
/// The remote address of the client that sent this request.
const ClientAddress clientAddress;
const ClientAddress clientAddress = ClientAddress.unknown;
/// The HTTP verb used in the request.
const string method = HttpMethod.GET;
/// The URL that was requested.
/// The URL that was requested, excluding any query parameters.
const string url = "";
/// A case-insensitive map of all request headers.
const(string[][string]) headers;
/// A list of all URL query parameters.
const QueryParameter[] queryParams;
/// The underlying stream used to read the body from the request.
InputStream!ubyte inputStream;
/**
* Gets a header as the specified type, or returns the default value if the
* header doesn't exist or cannot be converted to the desired type.
* Params:
* headerName = The name of the header to get, case-sensitive.
* defaultValue = The default value to return if the header doesn't exist
* or is invalid.
* Returns: The header value.
*/
T getHeaderAs(T)(string headerName, T defaultValue = T.init) const {
import std.conv : to, ConvException;
if (headerName !in headers || headers[headerName].length == 0) return defaultValue;
try {
return to!T(headers[headerName][0]);
} catch (ConvException e) {
return defaultValue;
}
}
/**
* Gets a query parameter with a given name, as the specified type, or
* returns the default value if the parameter doesn't exist.
* Params:
* paramName = The name of the parameter to get.
* defaultValue = The default value to return if the parameter doesn't
* exist or is invalid.
* Returns: The parameter value.
*/
T getParamAs(T)(string paramName, T defaultValue = T.init) const {
import std.conv : to, ConvException;
foreach (ref param; queryParams) {
if (param.key == paramName) {
foreach (string value; param.values) {
try {
return value.to!T;
} catch (ConvException e) {
continue;
}
}
// No value could be converted, short-circuit now.
return defaultValue;
}
}
return defaultValue;
}
/**
* Reads the body of this request and transfers it to the given output
* stream, limited by the request's "Content-Length" unless you choose to
* allow infinite reading. If the request includes a header for
* "Transfer-Encoding: chunked", then it will wrap the input stream in one
* which decodes HTTP chunked-encoding first.
* Params:
* outputStream = The output stream to transfer data to.
* allowInfiniteRead = Whether to allow reading the request even if the
* Content-Length header is missing or invalid. Use
* with caution!
* Returns: Either the number of bytes read, or a stream error.
*/
StreamResult readBody(S)(ref S outputStream, bool allowInfiniteRead = false) if (isByteOutputStream!S) {
import std.algorithm : min;
import std.string : toLower;
const long contentLength = getHeaderAs!long("Content-Length", -1);
if (contentLength < 0 && !allowInfiniteRead) {
return StreamResult(0);
}
InputStream!ubyte sIn;
if ("Transfer-Encoding" in headers && toLower(headers["Transfer-Encoding"][0]) == "chunked") {
sIn = inputStreamObjectFor(chunkedEncodingInputStreamFor(inputStream));
} else {
sIn = inputStream;
}
ulong bytesRead = 0;
ubyte[8192] buffer;
while (contentLength == -1 || bytesRead < contentLength) {
const ulong bytesToRead = (contentLength == -1)
? buffer.length
: min(contentLength - bytesRead, buffer.length);
StreamResult readResult = sIn.readFromStream(buffer[0 .. bytesToRead]);
if (readResult.hasError) {
return readResult;
}
if (readResult.count == 0) break;
StreamResult writeResult = outputStream.writeToStream(buffer[0 .. readResult.count]);
if (writeResult.hasError) {
return writeResult;
}
if (writeResult.count != readResult.count) {
return StreamResult(StreamError("Failed to write all bytes that were read to the output stream.", 1));
}
bytesRead += writeResult.count;
}
// If a content-length was provided, but we didn't read as many bytes as specified, return an error.
if (contentLength > 0 && bytesRead < contentLength) {
return StreamResult(StreamError("Failed to read body according to provided Content-Length.", 1));
}
return StreamResult(cast(uint) bytesRead);
}
/**
* Reads the request's body into a new byte array.
* Params:
* allowInfiniteRead = Whether to allow reading even without a valid
* Content-Length header.
* Returns: The byte array.
*/
ubyte[] readBodyAsBytes(bool allowInfiniteRead = false) {
auto sOut = byteArrayOutputStream();
StreamResult r = readBody(sOut, allowInfiniteRead);
if (r.hasError) throw new Exception(cast(string) r.error.message);
return sOut.toArray();
}
/**
* Reads the request's body into a new string.
* Params:
* allowInfiniteRead = Whether to allow reading even without a valid
* Content-Length header.
* Returns: The string content.
*/
string readBodyAsString(bool allowInfiniteRead = false) {
return cast(string) readBodyAsBytes(allowInfiniteRead);
}
}
// Test getHeaderAs
unittest {
InputStream!ubyte noOpInputStream = inputStreamObjectFor(arrayInputStreamFor!ubyte([]));
ServerHttpRequest r1 = ServerHttpRequest(
HttpVersion.V1,
ClientAddress.unknown,
HttpMethod.GET,
"/test",
["Content-Type": ["application/json"], "Test": ["123", "456"]],
[],
noOpInputStream
);
assert(r1.getHeaderAs!string("Content-Type") == "application/json");
assert(r1.getHeaderAs!string("content-type") == ""); // Case sensitivity.
assert(r1.getHeaderAs!int("Content-Type") == 0);
assert(r1.getHeaderAs!int("Test") == 123); // Check that we get the first header value.
assert(r1.getHeaderAs!string("Test") == "123");
}
// Test readBody
unittest {
ServerHttpRequest makeSampleRequest(S)(string[][string] headers, S inputStream) if (isByteInputStream!S) {
return ServerHttpRequest(
HttpVersion.V1,
ClientAddress.unknown,
HttpMethod.POST,
"/test",
headers,
[],
inputStreamObjectFor(inputStream)
);
}
auto sOut = byteArrayOutputStream();
// Base scenario with provided content length and correct values.
auto r1 = makeSampleRequest(["Content-Length": ["5"]], arrayInputStreamFor!ubyte([1, 2, 3, 4, 5]));
StreamResult result1 = r1.readBody(sOut, false);
assert(result1.hasCount);
assert(result1.count == 5);
assert(sOut.toArray() == [1, 2, 3, 4, 5]);
sOut.reset();
// If content length is missing, and we don't allow infinite read, don't read anything.
auto r2 = makeSampleRequest(["test": ["blah"]], arrayInputStreamFor!ubyte([1, 2, 3]));
StreamResult result2 = r2.readBody(sOut, false);
assert(result2.hasCount);
assert(result2.count == 0);
assert(sOut.toArray() == []);
sOut.reset();
// If content length is provided but is smaller than actual data, only read up to content length.
auto r3 = makeSampleRequest(["Content-Length": ["3"]], arrayInputStreamFor!ubyte([1, 2, 3, 4, 5]));
StreamResult result3 = r3.readBody(sOut, false);
assert(result3.hasCount);
assert(result3.count == 3);
assert(sOut.toArray() == [1, 2, 3]);
sOut.reset();
// If content length is provided but larger than actual data, a stream error should be returned.
auto r4 = makeSampleRequest(["Content-Length": ["8"]], arrayInputStreamFor!ubyte([1, 2, 3, 4, 5]));
StreamResult result4 = r4.readBody(sOut, false);
assert(result4.hasError);
assert(result4.error.code == 1);
assert(sOut.toArray().length == 5); // We should have read as much as we can from the request.
sOut.reset();
// If content length is not provided and we allow infinite read, read all body data.
auto r5 = makeSampleRequest(["test": ["blah"]], arrayInputStreamFor!ubyte([1, 2, 3, 4, 5]));
StreamResult result5 = r5.readBody(sOut, true);
assert(result5.hasCount);
assert(result5.count == 5);
assert(sOut.toArray() == [1, 2, 3, 4, 5]);
sOut.reset();
// If content length is provided, and we allow infinite read, respect the declared content length and only read that many bytes.
auto r6 = makeSampleRequest(["Content-Length": ["3"]], arrayInputStreamFor!ubyte([1, 2, 3, 4, 5]));
StreamResult result6 = r6.readBody(sOut, true);
assert(result6.hasCount);
assert(result6.count == 3);
assert(sOut.toArray() == [1, 2, 3]);
sOut.reset();
// Chunked-encoded data test: Write some chunked-encoded data to a buffer, and check that we can read it.
auto chunkedTestBytesOut = byteArrayOutputStream();
auto chunkedTestChunkedStream = ChunkedEncodingOutputStream!(ArrayOutputStream!ubyte*)(&chunkedTestBytesOut);
chunkedTestChunkedStream.writeToStream([1, 2]);
chunkedTestChunkedStream.writeToStream([3, 4, 5]);
chunkedTestChunkedStream.writeToStream([6, 7, 8]);
chunkedTestChunkedStream.writeToStream([9, 10]);
chunkedTestChunkedStream.closeStream();
ubyte[] chunkedData = chunkedTestBytesOut.toArray();
auto r7 = makeSampleRequest(
["Content-Length": ["10"], "Transfer-Encoding": ["chunked"]],
arrayInputStreamFor(chunkedData)
);
StreamResult result7 = r7.readBody(sOut, false);
assert(result7.hasCount);
assert(result7.count == 10);
assert(sOut.toArray() == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
sOut.reset();
}
/**
@ -54,35 +286,6 @@ public enum HttpMethod : string {
PATCH = "PATCH"
}
/**
* Attempts to parse an HttpMethod from a string.
* Params:
* s = The string to parse.
* Returns: An optional which may contain an HttpMethod, if one was parsed.
*/
Optional!HttpMethod parseHttpMethod(string s) {
// TODO: Remove this function now that we're using plain string HTTP methods.
import std.uni : toUpper;
import std.string : strip;
static foreach (m; EnumMembers!HttpMethod) {
if (s == m) return Optional!HttpMethod.of(m);
}
const cleanStr = strip(toUpper(s));
static foreach (m; EnumMembers!HttpMethod) {
if (cleanStr == m) return Optional!HttpMethod.of(m);
}
return Optional!HttpMethod.empty;
}
unittest {
assert(parseHttpMethod("GET") == Optional!HttpMethod.of(HttpMethod.GET));
assert(parseHttpMethod("get") == Optional!HttpMethod.of(HttpMethod.GET));
assert(parseHttpMethod(" geT ") == Optional!HttpMethod.of(HttpMethod.GET));
assert(parseHttpMethod("PATCH") == Optional!HttpMethod.of(HttpMethod.PATCH));
assert(parseHttpMethod(" not a method!") == Optional!HttpMethod.empty);
assert(parseHttpMethod("") == Optional!HttpMethod.empty);
}
/// Stores a single query parameter's key and values.
struct QueryParameter {
string key;
@ -104,6 +307,8 @@ QueryParameter[] parseQueryParameters(string url) {
string paramsStr = url[paramsStartIdx + 1 .. $];
QueryParameter[] params;
import std.array : RefAppender, appender; // TODO: Get rid of stdlib usage of std.array!
RefAppender!(QueryParameter[]) app = appender(&params);
size_t idx = 0;
while (idx < paramsStr.length) {
// First, isolate the text up to the next '&' separator.
@ -145,7 +350,7 @@ QueryParameter[] parseQueryParameters(string url) {
}
// Otherwise, add a new query parameter.
if (!keyExists) {
params ~= QueryParameter(key, [val]);
app ~= QueryParameter(key, [val]);
}
// Advance our current index pointer to the start of the next query parameter.
// (past the '&' character separating query parameters)
@ -170,6 +375,11 @@ unittest {
assert(parseQueryParameters("test?test") == [QueryParameter("test", [""])]);
// Test parameter without a name.
assert(parseQueryParameters("test?=value") == [QueryParameter("", ["value"])]);
// Test URI-encoded parameter value.
assert(parseQueryParameters(
"test?key=this%20is%20a%20long%20sentence%21%28test%29") ==
[QueryParameter("key", ["this is a long sentence!(test)"])]
);
}
/**
@ -186,3 +396,11 @@ private ptrdiff_t indexOf(string s, char c, size_t offset = 0) {
}
return -1;
}
unittest {
assert(indexOf("test", 't', 0) == 0);
assert(indexOf("test", 't', 1) == 3);
assert(indexOf("", 't', 0) == -1);
assert(indexOf("test", 't', 100) == -1);
assert(indexOf("test", 'a', 0) == -1);
}

View File

@ -1,6 +1,6 @@
module handy_http_primitives.response;
import streams;
import streams : OutputStream;
import handy_http_primitives.multivalue_map;
@ -111,3 +111,25 @@ enum ContentTypes : string {
TEXT_HTML = "text/html",
TEXT_CSS = "text/css"
}
/**
* An exception that can be thrown while handling an HTTP request, to indicate
* that the server should return a specified response code, usually when you
* want to short-circuit due to an error.
*/
class HttpStatusException : Exception {
const StatusInfo status;
this(StatusInfo status, string message, Throwable next) {
super(message, next);
this.status = status;
}
this(StatusInfo status, string message) {
this(status, message, null);
}
this(StatusInfo status) {
this(status, "An error occurred while processing the request.", null);
}
}