Compare commits

..

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

9 changed files with 16 additions and 358 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

@ -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,7 +26,7 @@ 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`.
@ -42,10 +42,10 @@ 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.

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,7 +1,3 @@
/**
* 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;
@ -29,8 +25,6 @@ struct ServerHttpRequest {
const QueryParameter[] queryParams; 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 * Gets a header as the specified type, or returns the default value if the

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,19 +106,10 @@ 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"
} }
/** /**

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);
}