Compare commits

..

No commits in common. "main" and "v1.1.0" have entirely different histories.
main ... v1.1.0

11 changed files with 52 additions and 632 deletions

View File

@ -1,5 +1 @@
Copyright 2025 Andrew Lalis 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/
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.

View File

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

View File

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

View File

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

View File

@ -1,174 +0,0 @@
/**
* 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!");
}

View File

@ -1,7 +1,3 @@
/**
* Defines the core request handler interface that's the starting point for
* all HTTP request processing.
*/
module handy_http_primitives.handler; module handy_http_primitives.handler;
import handy_http_primitives.request; import handy_http_primitives.request;

View File

@ -183,14 +183,17 @@ struct MultiValueMap(
* k = The key to remove. * k = The key to remove.
*/ */
void remove(KeyType k) { void remove(KeyType k) {
import std.algorithm : remove;
long idx = indexOf(k); long idx = indexOf(k);
if (idx == -1) return; if (idx == -1) return;
if (entries.length == 1) { if (entries.length == 1) {
clear(); clear();
return; return;
} }
entries = entries.remove(idx); if (idx + 1 < entries.length) {
const i = cast(size_t) idx;
entries[i .. $ - 1] = entries[i + 1 .. $];
}
entries.length = entries.length - 1;
} }
/** /**
@ -443,11 +446,3 @@ unittest {
builder.add("a", "456"); builder.add("a", "456");
assert(builder.build()[] == [StringMultiValueMap.Entry("a", ["123", "456"])], builder.build().toString); 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");
}

View File

@ -1,8 +1,3 @@
/**
* 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; module handy_http_primitives;
public import handy_http_primitives.request; public import handy_http_primitives.request;
@ -10,5 +5,3 @@ public import handy_http_primitives.response;
public import handy_http_primitives.handler; public import handy_http_primitives.handler;
public import handy_http_primitives.optional; public import handy_http_primitives.optional;
public import handy_http_primitives.multivalue_map; public import handy_http_primitives.multivalue_map;
public import handy_http_primitives.builder;
public import handy_http_primitives.testing;

View File

@ -1,10 +1,6 @@
/**
* Defines the request structure and associated types that are generally used
* when dealing with a client's HTTP request.
*/
module handy_http_primitives.request; module handy_http_primitives.request;
import streams; import streams : InputStream;
import std.traits : EnumMembers; import std.traits : EnumMembers;
import handy_http_primitives.optional; import handy_http_primitives.optional;
@ -18,249 +14,15 @@ struct ServerHttpRequest {
/// The HTTP version of the request. /// The HTTP version of the request.
const HttpVersion httpVersion = HttpVersion.V1; const HttpVersion httpVersion = HttpVersion.V1;
/// The remote address of the client that sent this request. /// The remote address of the client that sent this request.
const ClientAddress clientAddress = ClientAddress.unknown; const ClientAddress clientAddress;
/// The HTTP verb used in the request. /// The HTTP verb used in the request.
const string method = HttpMethod.GET; const string method = HttpMethod.GET;
/// The URL that was requested, excluding any query parameters. /// The URL that was requested.
const string url = ""; const string url = "";
/// A case-insensitive map of all request headers. /// A case-insensitive map of all request headers.
const(string[][string]) 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. /// The underlying stream used to read the body from the request.
InputStream!ubyte inputStream; 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();
} }
/** /**
@ -292,6 +54,35 @@ public enum HttpMethod : string {
PATCH = "PATCH" 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. /// Stores a single query parameter's key and values.
struct QueryParameter { struct QueryParameter {
string key; string key;
@ -313,8 +104,6 @@ QueryParameter[] parseQueryParameters(string url) {
string paramsStr = url[paramsStartIdx + 1 .. $]; string paramsStr = url[paramsStartIdx + 1 .. $];
QueryParameter[] params; 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; size_t idx = 0;
while (idx < paramsStr.length) { while (idx < paramsStr.length) {
// First, isolate the text up to the next '&' separator. // First, isolate the text up to the next '&' separator.
@ -356,7 +145,7 @@ QueryParameter[] parseQueryParameters(string url) {
} }
// Otherwise, add a new query parameter. // Otherwise, add a new query parameter.
if (!keyExists) { if (!keyExists) {
app ~= QueryParameter(key, [val]); params ~= QueryParameter(key, [val]);
} }
// Advance our current index pointer to the start of the next query parameter. // Advance our current index pointer to the start of the next query parameter.
// (past the '&' character separating query parameters) // (past the '&' character separating query parameters)

View File

@ -1,7 +1,3 @@
/**
* 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; module handy_http_primitives.response;
import streams : OutputStream; import streams : OutputStream;
@ -19,40 +15,6 @@ struct ServerHttpResponse {
StringMultiValueMap headers; StringMultiValueMap headers;
/// The stream to which the response body is written. /// The stream to which the response body is written.
OutputStream!ubyte outputStream; 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);
}
} }
/** /**
@ -144,39 +106,8 @@ enum HttpStatus : StatusInfo {
enum ContentTypes : string { enum ContentTypes : string {
APPLICATION_JSON = "application/json", APPLICATION_JSON = "application/json",
APPLICATION_XML = "application/xml", APPLICATION_XML = "application/xml",
APPLICATION_OCTET_STREAM = "application/octet-stream",
APPLICATION_PDF = "application/pdf",
TEXT_PLAIN = "text/plain", TEXT_PLAIN = "text/plain",
TEXT_HTML = "text/html", 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);
}
} }

View File

@ -1,95 +0,0 @@
/**
* 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);
}