Compare commits
12 Commits
Author | SHA1 | Date |
---|---|---|
|
cbaf86821e | |
|
39bb474522 | |
|
8b198c58ee | |
|
bb1d04cfa3 | |
|
799da3ff34 | |
|
79261ce20a | |
|
a5cfdf771c | |
|
0a4e507fa6 | |
|
88c8590fec | |
|
774da2281e | |
|
5fbe682749 | |
|
262947110f |
|
@ -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
|
6
LICENSE
6
LICENSE
|
@ -1 +1,5 @@
|
|||
Handy-Http by Andrew Lalis is marked with CC0 1.0 Universal. To view a copy of this license, visit https://creativecommons.org/publicdomain/zero/1.0/
|
||||
Copyright 2025 Andrew Lalis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
2
dub.json
2
dub.json
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"fileVersion": 1,
|
||||
"versions": {
|
||||
"streams": "3.5.0"
|
||||
"streams": "3.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* 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());
|
||||
Object[string] contextData;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
ref withContextData(string key, Object obj) {
|
||||
this.contextData[key] = obj;
|
||||
return this;
|
||||
}
|
||||
|
||||
ServerHttpRequest build() {
|
||||
return ServerHttpRequest(
|
||||
httpVersion,
|
||||
clientAddress,
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
queryParams,
|
||||
inputStream,
|
||||
contextData
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unittest {
|
||||
class SampleContextData {
|
||||
string name;
|
||||
this(string name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
.withContextData("name", new SampleContextData("andrew"))
|
||||
.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);
|
||||
assert((cast(SampleContextData) r1.contextData["name"]).name == "andrew");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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!");
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
/**
|
||||
* Defines the core request handler interface that's the starting point for
|
||||
* all HTTP request processing.
|
||||
*/
|
||||
module handy_http_primitives.handler;
|
||||
|
||||
import handy_http_primitives.request;
|
||||
|
@ -8,5 +12,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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -183,17 +183,14 @@ struct MultiValueMap(
|
|||
* k = The key to remove.
|
||||
*/
|
||||
void remove(KeyType k) {
|
||||
import std.algorithm : remove;
|
||||
long idx = indexOf(k);
|
||||
if (idx == -1) return;
|
||||
if (entries.length == 1) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (idx + 1 < entries.length) {
|
||||
const i = cast(size_t) idx;
|
||||
entries[i .. $ - 1] = entries[i + 1 .. $];
|
||||
}
|
||||
entries.length = entries.length - 1;
|
||||
entries = entries.remove(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -446,3 +443,11 @@ unittest {
|
|||
builder.add("a", "456");
|
||||
assert(builder.build()[] == [StringMultiValueMap.Entry("a", ["123", "456"])], builder.build().toString);
|
||||
}
|
||||
|
||||
unittest {
|
||||
MultiValueMap!(string, string) map;
|
||||
map.add("a", "one");
|
||||
map.add("b", "two");
|
||||
map.add("c", "three");
|
||||
map.remove("a");
|
||||
}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* The handy_http_primitives module defines a set of primitive types and
|
||||
* interfaces that are shared among all Handy-Http libraries, and form the
|
||||
* basis of how requests are handled.
|
||||
*/
|
||||
module handy_http_primitives;
|
||||
|
||||
public import handy_http_primitives.request;
|
||||
|
@ -5,3 +10,5 @@ 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;
|
||||
public import handy_http_primitives.testing;
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
/**
|
||||
* Defines the request structure and associated types that are generally used
|
||||
* when dealing with a client's HTTP request.
|
||||
*/
|
||||
module handy_http_primitives.request;
|
||||
|
||||
import streams : InputStream;
|
||||
import streams;
|
||||
import std.traits : EnumMembers;
|
||||
|
||||
import handy_http_primitives.optional;
|
||||
|
@ -14,15 +18,249 @@ 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;
|
||||
/// Any additional data about this request that may be populated during handling.
|
||||
Object[string] contextData;
|
||||
|
||||
/**
|
||||
* 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 +292,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 +313,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(¶ms);
|
||||
size_t idx = 0;
|
||||
while (idx < paramsStr.length) {
|
||||
// First, isolate the text up to the next '&' separator.
|
||||
|
@ -145,7 +356,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 +381,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 +402,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);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
/**
|
||||
* Defines the HTTP response structure and associated types that are generally
|
||||
* used when formulating a response to a client's request.
|
||||
*/
|
||||
module handy_http_primitives.response;
|
||||
|
||||
import streams;
|
||||
import streams : OutputStream;
|
||||
|
||||
import handy_http_primitives.multivalue_map;
|
||||
|
||||
|
@ -15,6 +19,40 @@ struct ServerHttpResponse {
|
|||
StringMultiValueMap headers;
|
||||
/// The stream to which the response body is written.
|
||||
OutputStream!ubyte outputStream;
|
||||
|
||||
/**
|
||||
* Writes an array of bytes to the response's output stream.
|
||||
* Params:
|
||||
* bytes = The bytes to write.
|
||||
* contentType = The declared content type of the data, which is written
|
||||
* as the "Content-Type" header.
|
||||
*/
|
||||
void writeBodyBytes(ubyte[] bytes, string contentType = ContentTypes.APPLICATION_OCTET_STREAM) {
|
||||
import std.conv : to;
|
||||
headers.add("Content-Type", contentType);
|
||||
headers.add("Content-Length", to!string(bytes.length));
|
||||
// We trust that when we write to the output stream, the transport
|
||||
// implementation will handle properly formatting the headers and other
|
||||
// HTTP boilerplate response content prior to actually writing the body.
|
||||
auto result = outputStream.writeToStream(bytes);
|
||||
if (result.hasError) {
|
||||
throw new Exception(
|
||||
"Failed to write bytes to the response's output stream: " ~
|
||||
cast(string) result.error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a string of content to the response's output stream.
|
||||
* Params:
|
||||
* content = The content to write.
|
||||
* contentType = The declared content type of the data, which is written
|
||||
* as the "Content-Type" header.
|
||||
*/
|
||||
void writeBodyString(string content, string contentType = ContentTypes.TEXT_PLAIN) {
|
||||
writeBodyBytes(cast(ubyte[]) content, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -106,8 +144,39 @@ enum HttpStatus : StatusInfo {
|
|||
enum ContentTypes : string {
|
||||
APPLICATION_JSON = "application/json",
|
||||
APPLICATION_XML = "application/xml",
|
||||
APPLICATION_OCTET_STREAM = "application/octet-stream",
|
||||
APPLICATION_PDF = "application/pdf",
|
||||
|
||||
TEXT_PLAIN = "text/plain",
|
||||
TEXT_HTML = "text/html",
|
||||
TEXT_CSS = "text/css"
|
||||
TEXT_CSS = "text/css",
|
||||
TEXT_CSV = "text/csv",
|
||||
TEXT_JAVASCRIPT = "text/javascript",
|
||||
TEXT_MARKDOWN = "text/markdown",
|
||||
|
||||
IMAGE_JPEG = "image/jpeg",
|
||||
IMAGE_PNG = "image/png",
|
||||
IMAGE_SVG = "image/svg+xml"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* The testing module defines helper methods for testing your HTTP handling
|
||||
* code.
|
||||
*/
|
||||
module handy_http_primitives.testing;
|
||||
|
||||
import handy_http_primitives.response;
|
||||
|
||||
/**
|
||||
* Asserts that the given response's status matches an expected status.
|
||||
* Params:
|
||||
* response = The response to check.
|
||||
* expectedStatus = The expected status that the response should have.
|
||||
*/
|
||||
void assertStatus(in ServerHttpResponse response, in StatusInfo expectedStatus) {
|
||||
import std.format : format;
|
||||
assert(
|
||||
expectedStatus == response.status,
|
||||
format!"The HTTP response's status of %d (%s) didn't match the expected status %d (%s)."(
|
||||
response.status.code,
|
||||
response.status.text,
|
||||
expectedStatus.code,
|
||||
expectedStatus.text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
unittest {
|
||||
import handy_http_primitives.builder;
|
||||
ServerHttpResponseBuilder()
|
||||
.withStatus(HttpStatus.OK)
|
||||
.build()
|
||||
.assertStatus(HttpStatus.OK);
|
||||
}
|
||||
|
||||
// Some common status assertions:
|
||||
|
||||
void assertStatusOk(in ServerHttpResponse response) {
|
||||
assertStatus(response, HttpStatus.OK);
|
||||
}
|
||||
|
||||
void assertStatusNotFound(in ServerHttpResponse response) {
|
||||
assertStatus(response, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
void assertStatusBadRequest(in ServerHttpResponse response) {
|
||||
assertStatus(response, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
void assertStatusUnauthorized(in ServerHttpResponse response) {
|
||||
assertStatus(response, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
void assertStatusForbidden(in ServerHttpResponse response) {
|
||||
assertStatus(response, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
void assertStatusInternalServerError(in ServerHttpResponse response) {
|
||||
assertStatus(response, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given response has a header with a given value.
|
||||
* Params:
|
||||
* response = The response to check.
|
||||
* header = The name of the header to check the value of.
|
||||
* expectedValue = The expected value of the header.
|
||||
*/
|
||||
void assertHasHeader(in ServerHttpResponse response, string header, string expectedValue) {
|
||||
import std.format : format;
|
||||
assert(
|
||||
response.headers.contains(header),
|
||||
format!"The HTTP response doesn't have a header named \"%s\"."(header)
|
||||
);
|
||||
string value = response.headers.getFirst(header).orElseThrow();
|
||||
assert(
|
||||
value == expectedValue,
|
||||
format!"The HTTP response's header \"%s\" with value \"%s\" didn't match the expected value \"%s\"."(
|
||||
header,
|
||||
value,
|
||||
expectedValue
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
unittest {
|
||||
import streams;
|
||||
import handy_http_primitives.builder;
|
||||
ArrayOutputStream!ubyte bufferOut = byteArrayOutputStream();
|
||||
ServerHttpResponse r1 = ServerHttpResponseBuilder()
|
||||
.withOutputStream(&bufferOut)
|
||||
.build();
|
||||
r1.writeBodyString("Hello, world!");
|
||||
r1.assertHasHeader("Content-Type", ContentTypes.TEXT_PLAIN);
|
||||
}
|
Loading…
Reference in New Issue