Compare commits

..

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

7 changed files with 11 additions and 300 deletions

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,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,158 +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());
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

@ -5,4 +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;

View File

@ -119,10 +119,6 @@ struct ServerHttpRequest {
} }
bytesRead += writeResult.count; 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); return StreamResult(cast(uint) bytesRead);
} }
@ -153,110 +149,6 @@ struct ServerHttpRequest {
} }
} }
// 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();
}
/** /**
* Enumeration of all possible HTTP request versions. * Enumeration of all possible HTTP request versions.
*/ */

View File

@ -111,25 +111,3 @@ enum ContentTypes : string {
TEXT_HTML = "text/html", TEXT_HTML = "text/html",
TEXT_CSS = "text/css" 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);
}
}