Compare commits
No commits in common. "main" and "v1.1.0" have entirely different histories.
2
dub.json
2
dub.json
|
@ -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",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"streams": "3.6.0"
|
"streams": "3.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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!");
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
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;
|
||||||
|
@ -14,247 +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;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -286,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;
|
||||||
|
@ -307,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(¶ms);
|
|
||||||
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.
|
||||||
|
@ -350,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)
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue