diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d8fa84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.dub +docs.json +__dummy.html +docs/ +/handy-http-2 +handy-http-2.so +handy-http-2.dylib +handy-http-2.dll +handy-http-2.a +handy-http-2.lib +handy-http-2-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +*-test-library diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..3a3da87 --- /dev/null +++ b/dub.json @@ -0,0 +1,14 @@ +{ + "authors": [ + "Andrew Lalis" + ], + "copyright": "Copyright © 2024, Andrew Lalis", + "description": "Improved HTTP server based on handy-http.", + "license": "MIT", + "name": "handy-http-2", + "buildRequirements": ["allowWarnings"], + "subPackages": [ + "./sub-packages/http-parser/", + "./sub-packages/http-primitives/" + ] +} \ No newline at end of file diff --git a/run.d b/run.d new file mode 100755 index 0000000..1cbcbc0 --- /dev/null +++ b/run.d @@ -0,0 +1,48 @@ +#!/usr/bin/env rdmd + + +module run; + +import std.stdio; +import std.process; +import std.string; + +int main(string[] args) { + if (args.length < 2) { + stderr.writeln("Missing required sub-command. Should be one of the following:"); + stderr.writeln(" - \"unit-test\": Build and run unit tests for the entire project."); + stderr.writeln(" - \"integration-test\": Build and run integration tests for the entire project."); + return 1; + } + string command = args[1].toLower.strip; + if (command == "unit-test") { + return doUnitTests(args[2..$]); + } else if (command == "integration-test") { + return doIntegrationTests(args[2..$]); + } + stderr.writefln!"Invalid sub-command: %s."(command); + return 1; +} + +int doUnitTests(string[] args) { + const subPackages = ["http-primitives", "http-parser"]; + uint subPackagesSuccessful = 0; + foreach (subPackage; subPackages) { + writefln!"Running unit tests for sub-package \"%s\"..."(subPackage); + int exitCode = wait(spawnProcess(["dub", "test", ":" ~ subPackage])); + if (exitCode == 0) subPackagesSuccessful++; + writeln(); + } + writefln!"Unit tests were successful in %d / %d sub-packages.\n"(subPackagesSuccessful, subPackages.length); + if (subPackagesSuccessful != subPackages.length) { + writeln("Skipping testing main package because sub-packages have errors."); + return 1; + } + writeln("Running unit tests for main package..."); + return wait(spawnProcess(["dub", "test"])); +} + +int doIntegrationTests(string[] args) { + writeln("Integration tests not yet implemented."); + return 1; +} diff --git a/source/app.d b/source/app.d new file mode 100644 index 0000000..c3eec7f --- /dev/null +++ b/source/app.d @@ -0,0 +1,6 @@ +import std.stdio; + +void main() +{ + writeln("Edit source/app.d to start your project."); +} diff --git a/sub-packages/http-parser/README.md b/sub-packages/http-parser/README.md new file mode 100644 index 0000000..1d4b147 --- /dev/null +++ b/sub-packages/http-parser/README.md @@ -0,0 +1,4 @@ +# http-parser + +This sub-package is responsible for defining the functions that parse the +HTTP request information from data the client has sent. diff --git a/sub-packages/http-parser/dub.json b/sub-packages/http-parser/dub.json new file mode 100644 index 0000000..ca138bb --- /dev/null +++ b/sub-packages/http-parser/dub.json @@ -0,0 +1,4 @@ +{ + "name": "http-parser", + "targetType": "library" +} \ No newline at end of file diff --git a/sub-packages/http-parser/source/http_parser/package.d b/sub-packages/http-parser/source/http_parser/package.d new file mode 100644 index 0000000..ddf2cd9 --- /dev/null +++ b/sub-packages/http-parser/source/http_parser/package.d @@ -0,0 +1 @@ +module http_parser; diff --git a/sub-packages/http-primitives/README.md b/sub-packages/http-primitives/README.md new file mode 100644 index 0000000..19dad6c --- /dev/null +++ b/sub-packages/http-primitives/README.md @@ -0,0 +1,4 @@ +# http-primitives + +This sub-package defines the primitive HTTP types that are used throughout the +project, like `HttpRequest`, `HttpResponse`, `HttpStatus`, and so on. diff --git a/sub-packages/http-primitives/dub.json b/sub-packages/http-primitives/dub.json new file mode 100644 index 0000000..3e5a556 --- /dev/null +++ b/sub-packages/http-primitives/dub.json @@ -0,0 +1,4 @@ +{ + "name": "http-primitives", + "targetType": "library" +} \ No newline at end of file diff --git a/sub-packages/http-primitives/source/http_primitives/multivalue_map.d b/sub-packages/http-primitives/source/http_primitives/multivalue_map.d new file mode 100644 index 0000000..9a77970 --- /dev/null +++ b/sub-packages/http-primitives/source/http_primitives/multivalue_map.d @@ -0,0 +1,433 @@ +/** + * An implementation of a multi-valued mapping, where one key may map to one + * or more values. + */ +module http_primitives.multivalue_map; + +import http_primitives.optional; + +/** + * A multi-valued mapping, where a key is mapped to one or more values. The map + * can optionally be sorted by keys for O(log(n)) lookup and retrieval, and + * O(n*log(n)) insertion, instead of the default linear search. + */ +struct MultiValueMap(KeyType, ValueType, bool Sorted = true, alias KeySort = (a, b) => a < b) { + /// The internal structure used to store each key and set of values. + static struct Entry { + /// The key for this entry. + KeyType key; + + /** + * The list of values associated with this entry's key. This always + * contains at least one value. + */ + ValueType[] values; + + /** + * Gets a human-readable string representation of this entry. + * Returns: A string representation of this entry. + */ + string toString() const { + import std.conv : to; + import std.algorithm : map, joiner; + import std.array : array; + + string keyStr = key.to!string; + string valuesStr = values + .map!(v => "\""~v.to!string~"\"") + .joiner(", ").array.to!string; + return "\"" ~ keyStr ~ "\": " ~ valuesStr; + } + } + + /// The internal, sorted array of entries. + private Entry[] entries; + + /** + * Finds the index of the entry with a given key in the internal array. + * Params: + * k = The key to search for. + * Returns: The index if it was found, or -1 if it doesn't exist. + */ + private long indexOf(KeyType k) const { + if (entries.length == 0) return -1; + if (entries.length == 1) { + return entries[0].key == k ? 0 : -1; + } + static if (Sorted) { + size_t startIdx = 0; + size_t endIdx = entries.length - 1; + while (startIdx <= endIdx) { + size_t mid = startIdx + (endIdx - startIdx) / 2; + if (entries[mid].key == k) return mid; + if (KeySort(entries[mid].key, k)) { + startIdx = mid + 1; + } else { + endIdx = mid - 1; + } + } + return -1; + } else { + for (size_t idx = 0; idx < entries.length; idx++) { + if (entries[idx].key == k) return idx; + } + return -1; + } + } + + /** + * Attempts to get the entry for a given key. Complexity is O(log(keyCount)). + * Params: + * k = The key to look for. + * Returns: An optional that may contain the entry that was found. + */ + private Optional!Entry getEntry(KeyType k) { + long idx = indexOf(k); + if (idx == -1) return Optional!Entry.empty(); + return Optional!Entry.of(entries[cast(size_t) idx]); + } + + /** + * Gets the number of unique keys in this map. + * Returns: The number of unique keys in this map. + */ + size_t length() const { + return entries.length; + } + + /** + * Determines if this map contains a value for the given key. + * Params: + * k = The key to search for. + * Returns: True if at least one value exists for the given key. + */ + bool contains(KeyType k) const { + return indexOf(k) != -1; + } + + /** + * Gets a list of all keys in this map, allocated in a new array. + * Returns: The list of keys in this map. + */ + KeyType[] keys() const { + KeyType[] keysArray = new KeyType[this.length()]; + foreach (size_t i, const Entry e; entries) { + keysArray[i] = e.key; + } + return keysArray; + } + + /** + * Gets all values associated with a given key, allocated in a new array. + * Params: + * k = The key to get the values of. + * Returns: The values associated with the given key, or an empty array if + * no values exist for the key. + */ + ValueType[] getAll(KeyType k) const { + long idx = indexOf(k); + if (idx == -1) return []; + return entries[cast(size_t) idx].values.dup; + } + + /** + * Gets the first value associated with a given key, as per the order in + * which the values were inserted. + * Params: + * k = The key to get the first value of. + * Returns: An optional contains the value, if there is at least one value + * for the given key. + */ + Optional!ValueType getFirst(KeyType k) const { + long idx = indexOf(k); + if (idx == -1) return Optional!ValueType.empty(); + return Optional!ValueType.of(entries[cast(size_t) idx].values[0]); + } + + /** + * Adds a single key -> value pair to the map, with time complexity of + * O(n*log(n)) due to sorting the new entry by its key. + * Params: + * k = The key. + * v = The value associated with the key. + */ + void add(KeyType k, ValueType v) { + long idx = indexOf(k); + if (idx == -1) { + entries ~= Entry(k, [v]); + static if (Sorted) { + import std.algorithm.sorting : sort; + sort!((a, b) => KeySort(a.key, b.key))(entries); + } + } else { + entries[cast(size_t) idx].values ~= v; + } + } + + /** + * Clears this map of all values. + */ + void clear() { + entries.length = 0; + } + + /** + * Removes a key from the map, thus removing all values associated with + * that key. + * Params: + * k = The key to remove. + */ + void remove(KeyType k) { + 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; + } + + /** + * Gets this multivalue map as an associative array, where each key is + * mapped to a list of values. + * Returns: The associative array. + */ + ValueType[][KeyType] asAssociativeArray() const { + ValueType[][KeyType] aa; + foreach (const Entry entry; entries) { + aa[entry.key] = entry.values.dup; + } + return aa; + } + + /** + * Constructs a multivalued map from an associative array. + * Params: + * aa = The associative array to use. + * Returns: The multivalued map. + */ + static MultiValueMap fromAssociativeArray(ValueType[][KeyType] aa) { + MultiValueMap m; + foreach (KeyType k, ValueType[] values; aa) { + foreach (ValueType v; values) { + m.add(k, v); + } + } + return m; + } + + /** + * Constructs a multivalued map from an associative array of single values. + * Params: + * aa = The associative array to use. + * Returns: The multivalued map. + */ + static MultiValueMap fromAssociativeArray(ValueType[KeyType] aa) { + MultiValueMap m; + foreach (KeyType k, ValueType v; aa) { + m.add(k, v); + } + return m; + } + + /** + * An efficient builder that can be used to construct a multivalued map + * with successive `add` calls, which is more efficient than doing so + * directly due to the builder's deferred sorting. + */ + static struct Builder { + import std.array; + + private MultiValueMap m; + private RefAppender!(Entry[]) entryAppender; + + /** + * Adds a key -> value pair to the builder's map. + * Params: + * k = The key. + * v = The value associated with the key. + * Returns: A reference to the builder, for method chaining. + */ + ref Builder add(KeyType k, ValueType v) { + if (entryAppender.data is null) entryAppender = appender(&m.entries); + long idx = this.indexOf(k); + if (idx == -1) { + entryAppender ~= Entry(k, [v]); + } else { + m.entries[cast(size_t) idx].values ~= v; + } + return this; + } + + /** + * Builds the multivalued map. + * Returns: The map that was created. + */ + MultiValueMap build() { + if (m.entries.length == 0) return m; + static if (Sorted) { + import std.algorithm.sorting : sort; + sort!((a, b) => KeySort(a.key, b.key))(m.entries); + } + return m; + } + + private long indexOf(KeyType k) { + foreach (i, entry; m.entries) { + if (entry.key == k) return i; + } + return -1; + } + } + + // OPERATOR OVERLOADS below here + + /** + * Implements the empty index operator, which just returns the entire list + * of entries in this map. + * Returns: The list of entries in this map. + */ + inout(Entry)[] opIndex() inout { + return entries; + } + + /** + * Convenience overload to get the first value for a given key. Note: this + * will throw an exception if no values exist for the given key. To avoid + * this, use `getFirst` and deal with the missing value yourself. + * Params: + * key = The key to get the value of. + * Returns: The first value for the given key. + */ + ValueType opIndex(KeyType key) const { + import std.conv : to; + return getFirst(key).orElseThrow("No values exist for key " ~ key.to!string ~ "."); + } + + /** + * `opApply` implementation to allow iterating over this map by all pairs + * of keys and values. + * Params: + * dg = The foreach body that uses each key -> value pair. + * Returns: The result of the delegate call. + */ + int opApply(int delegate(const ref KeyType, const ref ValueType) dg) const { + int result = 0; + foreach (const Entry entry; entries) { + foreach (ValueType value; entry.values) { + result = dg(entry.key, value); + if (result) break; + } + } + return result; + } + + /** + * Implements opBinaryRight for the "in" operator, such that `k in m` will + * resolve to the list of values for key `k` in the multivalue map `m` if + * that key exists, or `null` if not. + * + * Params: + * lhs = The key to use. + * Returns: A list of values for the given key, or null if no such key exists. + * --- + * StringMultiValueMap m; + * m.add("a", "hello"); + * assert("a" in m); + * assert(("a" in m) == ["hello"]); + * assert("b" !in m); + * assert(("b" in m) is null); + * --- + */ + ValueType[] opBinaryRight(string op : "in")(string lhs) { + Optional!Entry optionalEntry = this.getEntry(lhs); + if (optionalEntry) { + Entry entry = optionalEntry.value; + return entry.values; + } + return null; + } + + /** + * Converts this map into a human-readable string which lists each key and + * all of the values for that key. + * Returns: A string representation of this map. + */ + string toString() const { + import std.format : format; + return format!"%(%s\n%)"(entries); + } +} + +/** + * A multivalued map of strings, where each string key refers to zero or more + * string values. All keys are case-sensitive. + */ +alias StringMultiValueMap = MultiValueMap!(string, string); + +unittest { + StringMultiValueMap m; + m.add("a", "hello"); + assert(m.getFirst("a").orElseThrow == "hello"); + m.add("b", "bye"); + assert(m.getFirst("b").orElseThrow == "bye"); + assert(m.asAssociativeArray == ["a": ["hello"], "b": ["bye"]]); + assert(m["b"] == "bye"); + m.remove("a"); + assert(!m.contains("a")); + m.add("b", "hello"); + assert(m.getAll("b") == ["bye", "hello"]); + m.clear(); + assert(m.length == 0); + assert(!m.contains("a")); + assert(!m.contains("b")); + + auto m2 = StringMultiValueMap.fromAssociativeArray(["a": "123", "b": "abc"]); + assert(m2["a"] == "123"); + assert(m2["b"] == "abc"); + + auto m3 = StringMultiValueMap.fromAssociativeArray(["a": [""], "b": [""], "c": ["hello"]]); + assert(m3.contains("a")); + assert(m3["a"] == ""); + assert(m3.contains("b")); + assert(m3["b"] == ""); + assert(m3.contains("c")); + assert(m3["c"] == "hello"); + + // Test that opApply works: + int n = 0; + foreach (key, value; m3) { + n++; + } + assert(n == 3); + + // Test opBinaryRight with "in" operator. + StringMultiValueMap m4; + m4.add("a", "1"); + assert("a" in m4); + assert("b" !in m4); + auto valuesA = "a" in m4; + assert(valuesA == ["1"]); + auto valuesB = "b" in m4; + assert(valuesB is null); + + // Test opIndex with an empty index. + StringMultiValueMap m5; + assert(m5[] == []); + m5.add("a", "123"); + assert(m5[] == [StringMultiValueMap.Entry("a", ["123"])]); + + // test on a const instance + const(StringMultiValueMap) m6 = m5; + assert(m6[] == [StringMultiValueMap.Entry("a", ["123"])]); + + // test the builder with multi-values + StringMultiValueMap.Builder builder; + builder.add("a", "123"); + builder.add("a", "456"); + assert(builder.build()[] == [StringMultiValueMap.Entry("a", ["123", "456"])], builder.build().toString); +} diff --git a/sub-packages/http-primitives/source/http_primitives/optional.d b/sub-packages/http-primitives/source/http_primitives/optional.d new file mode 100644 index 0000000..156e3fc --- /dev/null +++ b/sub-packages/http-primitives/source/http_primitives/optional.d @@ -0,0 +1,133 @@ +/** + * Module that defines an `Optional` type, which is a simplified version of + * Phobos' Nullable, that also supports mapping the underlying data. + */ +module http_primitives.optional; + +import std.typecons : Nullable; + +/** + * A simple wrapper around a value to make it optionally present. + */ +struct Optional(T) { + /// The internal value of this optional. + T value; + + /// Whether this optional is empty. + bool isNull = true; + + /** + * Constructs an optional value using a given value. + * Params: + * value = The value to use. + * Returns: An optional that contains the given value. + */ + static Optional!T of(T value) { + return Optional!T(value, false); + } + + /** + * Constructs an optional value using a Phobos nullable. + * Params: + * nullableValue = The nullable value to use. + * Returns: An optional that contains the given nullable value. + */ + static Optional!T of (Nullable!T nullableValue) { + if (nullableValue.isNull) return Optional!T.empty(); + return Optional!T.of(nullableValue.get); + } + + /** + * Constructs an optional that's empty. + * Returns: An optional that is empty. + */ + static Optional!T empty() { + return Optional!T(T.init, true); + } + + /** + * Converts this optional to a Phobos-style Nullable. + * Returns: A `Nullable!T` representing this optional. + */ + Nullable!T asNullable() { + Nullable!T n; + if (!this.isNull) { + n = this.value; + } + return n; + } + + /** + * Gets the value of this optional if it exists, otherwise uses a given + * default value. + * Params: + * defaultValue = The value to return if no default value exists. + * Returns: The value of the optional, or the default value if this + * optional is empty. + */ + T orElse(T defaultValue) { + if (this.isNull) return defaultValue; + return this.value; + } + + /** + * Gets the value of this optional if it exists, or throws an exception. + * Params: + * msg = A message to put in the exception. + * Returns: The value of this optional. + */ + T orElseThrow(string msg = "Optional value is null.") { + if (this.isNull) throw new Exception(msg); + return this.value; + } + + /** + * Gets the value of this optional if it exists, or throws an exception as + * produced by the given delegate. + * Params: + * exceptionSupplier = A delegate that returns an exception to throw if + * this optional is null. + * Returns: The value of this optional. + */ + T orElseThrow(Exception delegate() exceptionSupplier) { + if (this.isNull) throw exceptionSupplier(); + return this.value; + } + + /** + * Provides a mechanism to allow usage in boolean expressions. + * Returns: true if non-null, false if null + * --- + * auto optInt = Optional!int.empty(); + * assert(!optInt); + * auto optStr = Optional!string.of("Hello"); + * assert(optStr); + * --- + */ + bool opCast(B : bool)() const { + return !this.isNull; + } +} + +/** + * Maps the value of a given optional to another type using a given function. + * Params: + * opt = The optional to map. + * Returns: An optional whose type is the return-type of the given `fn` + * template argument function. + */ +auto mapIfPresent(alias fn, T)(Optional!T opt) { + alias U = typeof(fn(T.init)); + if (opt.isNull) return Optional!U.empty(); + return Optional!U.of(fn(opt.value)); +} + +unittest { + Optional!string s = Optional!string.of("hello"); + assert(!s.isNull); + assert(s.value == "hello"); + assert(s); // test boolean conversion + Optional!int mapped = s.mapIfPresent!(str => 1); + assert(!mapped.isNull); + assert(mapped.value == 1); +} diff --git a/sub-packages/http-primitives/source/http_primitives/package.d b/sub-packages/http-primitives/source/http_primitives/package.d new file mode 100644 index 0000000..74b6231 --- /dev/null +++ b/sub-packages/http-primitives/source/http_primitives/package.d @@ -0,0 +1,5 @@ +module http_primitives; + +public import http_primitives.request; +public import http_primitives.optional; +public import http_primitives.multivalue_map; diff --git a/sub-packages/http-primitives/source/http_primitives/ranges.d b/sub-packages/http-primitives/source/http_primitives/ranges.d new file mode 100644 index 0000000..b6320e2 --- /dev/null +++ b/sub-packages/http-primitives/source/http_primitives/ranges.d @@ -0,0 +1,410 @@ +/** + * This module defines input and output ranges that map onto sockets, to enable + * easy reading and writing of data using all the benefits of ranges. It also + * defines the interfaces used by HttpRequest and HttpResponse to allow + * pluggable range implementations, useful for testing. + */ +module http_primitives.ranges; + +import std.exception; +import std.format; +import std.algorithm : min; +import std.socket; + +import std.stdio; + +/** + * An input range for reading from a Socket. + */ +struct SocketInputRange(size_t BufferSize) { + /// The internal socket that serves as this range's data source. + Socket socket; + /// The internal (stack-allocated) buffer that this range uses. + ubyte[BufferSize] buffer; + /// The index representing the end (exclusive) of the data in the buffer. + size_t bufferIdx = 0; + /// Internal flag used to mark the socket as closed and thus this range empty. + bool closed = false; + + this(Socket socket, bool initialRead = true) { + this.socket = socket; + if (initialRead) { + popFront(); + } + } + + /** + * Determines whether this socket input range is empty, which is true only + * if we determine that the socket has been closed. Note that calling + * `front()` may return an empty slice of the buffer if no data has been + * received yet. + * Returns: True if the socket has been closed and no more data can be read. + */ + bool empty() { + return closed; + } + + /** + * Gets a slice to the data currently held in this range's buffer, which + * may be empty (length of 0), even if `empty` returns false. + * Returns: A slice to the data currently in this range's buffer. + */ + ubyte[] front() { + return buffer[0 .. bufferIdx]; + } + + /** + * Discards the current contents of this range's buffer, and attempts to + * receive more data from the socket if it's still alive. Warning! This + * method will BLOCK if the underlying socket is blocking! + */ + void popFront() { + if (!socket.isAlive) { + closed = true; + return; + } + ptrdiff_t bytesRead = socket.receive(buffer); + if (bytesRead == 0) { + closed = true; + } else if (bytesRead == Socket.ERROR) { + closed = true; + throw new SocketRangeException(lastSocketError()); + } else { + bufferIdx = bytesRead; + } + } +} + +/** + * An output range for writing to a Socket. Serves as an output range for both + * `ubyte` and `ubyte[]`, by using a template-defined buffer size. + */ +struct SocketOutputRange(size_t BufferSize) { + /// The internal socket that's written to. + Socket socket; + /// The buffer to which data is first written before flushing to the socket. + ubyte[BufferSize] buffer; + /// The index of the buffer at which new data is written. + size_t bufferIdx = 0; + + /** + * Writes an array of bytes to the range. If this range's internal buffer + * becomes full as a result of this method call, it will `flush()` and + * write to the underlying socket. + * Params: + * bytes = The bytes to write. + * Throws: `SocketRangeException` if flushing to the underlying socket fails. + */ + void put(ubyte[] bytes) { + size_t dataIdx = 0; + while (dataIdx < bytes.length) { + const size_t bytesLeftToSend = bytes.length - dataIdx; + const size_t bufferSpace = BufferSize - bufferIdx; + const size_t bytesToCopy = min(bufferSpace, bytesLeftToSend); + buffer[bufferIdx .. (bufferIdx + bytesToCopy)] = bytes[dataIdx .. (dataIdx + bytesToCopy)]; + dataIdx += bytesToCopy; + bufferIdx += bytesToCopy; + if (bufferIdx == BufferSize) { + flush(); + } + } + } + + /** + * Writes a single byte to the range. If this range's internal buffer + * becomes full as a result of this method call, it will `flush()` and + * write to the underlying socket. + * Params: + * singleByte = The byte to write. + * Throws: `SocketRangeException` if flushing to the underlying socket fails. + */ + void put(ubyte singleByte) { + buffer[bufferIdx++] = singleByte; + if (bufferIdx == BufferSize) { + flush(); + } + } + + /** + * Writes a value to the range as a big-endian (network byte order) set of + * bytes. Only works for integrals, chars, booleans, and float/double. See + * `std.bitmanip.nativeToBigEndian` for details on the conversion. + * Params: + * value = The value to write. + */ + void put(T)(const T value) { + import std.bitmanip : nativeToBigEndian; + auto bytes = nativeToBigEndian(value); + this.put(bytes); + } + + /** + * Flushes any data in the buffer to the underlying socket. + * Throws: `SocketRangeException` if sending data fails. + */ + void flush() { + if (bufferIdx == 0) return; + const ptrdiff_t bytesSent = socket.send(buffer[0..bufferIdx]); + if (bytesSent == Socket.ERROR) { + throw new SocketRangeException(lastSocketError()); + } else if (bytesSent != bufferIdx) { + throw new SocketRangeException( + format!"Failed to send all %d bytes. Only sent %d."(bufferIdx, bytesSent) + ); + } + bufferIdx = 0; + } +} + +/** + * An exception representing a socket IO error. + */ +class SocketRangeException : Exception { + mixin basicExceptionCtors; +} + +version(unittest) { + /** + * A convenience for unit tests, this test instance contains initialized + * sockets and ranges with a configured buffer size. + */ + struct TestInstance(size_t outBufferSize, size_t inBufferSize) { + Socket outputSocket; + Socket inputSocket; + SocketOutputRange!(outBufferSize) outputRange; + SocketInputRange!(inBufferSize) inputRange; + + static TestInstance create() { + Socket[2] pair = socketPair(); + return TestInstance( + pair[0], + pair[1], + SocketOutputRange!(outBufferSize)(pair[0]), + SocketInputRange!(inBufferSize)(pair[1], false) + ); + } + } + + alias StandardTestInstance = TestInstance!(4096, 4096); +} + +// Test basic reading and writing. +unittest { + auto t = StandardTestInstance.create(); + t.outputRange.put(cast(ubyte) 42); + assert(t.outputRange.bufferIdx == 1); // Assert that the byte was put into the range's buffer. + t.outputRange.flush(); + assert(t.outputRange.bufferIdx == 0); // Assert that the data was written and the buffer reset. + t.outputSocket.close(); + + assert(!t.inputRange.empty); // The input range should initially not be empty because the socket is not detected as dead yet. + assert(t.inputRange.front.length == 0); // Because the test instance's input range has `initialRead` as false, no data has been read yet. + t.inputRange.popFront(); + assert(!t.inputRange.empty); + assert(t.inputRange.front.length == 1); + assert(t.inputRange.front[0] == 42); + t.inputSocket.close(); + t.inputRange.popFront(); // We need to attempt to read once more to determine if the socket has closed. + assert(t.inputRange.empty); // Assert that the input range is indeed empty now. +} + +// Test reading and writing big chunks that exceed the size limits. +unittest { + import std.file; + import std.path; + import std.array; + + auto t = TestInstance!(128, 128).create(); + string filePath = buildPath("sub-packages", "http-primitives", "source", "http_primitives", "ranges.d"); + string content = readText(filePath); + assert(content.length > 4096); + + // Write the entire chunk of data to the output. + t.outputRange.put(cast(ubyte[]) content); + t.outputRange.flush(); + t.outputSocket.close(); + + // Now read and append the data to an appender so we can check it. + Appender!string app; + while (!t.inputRange.empty) { + app ~= cast(string) t.inputRange.front; + t.inputRange.popFront(); + } + assert(content == app[]); +} + +// Test reading and writing non-byte types (integral, bool, etc.) +unittest { + import std.bitmanip : bigEndianToNative; + + auto t = StandardTestInstance.create(); + + long value = 123_456_789_000; + t.outputRange.put(value); + t.outputRange.flush(); + t.inputRange.popFront(); + assert(t.inputRange.front.length == 8); + ubyte[8] bytes = t.inputRange.front[0..8]; + long readValue = bigEndianToNative!long(bytes); + assert(readValue == value); + + bool bValue = false; + t.outputRange.put(bValue); + t.outputRange.flush(); + t.inputRange.popFront(); + assert(t.inputRange.front.length == 1); + ubyte[1] bytes1 = t.inputRange.front[0..1]; + bool readBValue = bigEndianToNative!bool(bytes1); + assert(readBValue == bValue); + + t.outputSocket.close(); + t.inputSocket.close(); +} + +// Polymorphic OOP-Style ranges: + +/** + * An interface for an output range to which bytes, and some other types + * convertible to bytes, may be written. The underlying implementation is + * likely buffered, so call `flush()` to write the buffered data once ready. + */ +interface ResponseOutputRange { + void put(ubyte singleByte); + void put(ubyte[] bytes); + void put(T)(const T value); + void flush(); +} + +/** + * An interface for an input range from which chunks of bytes can be read. + */ +interface RequestInputRange { + bool empty(); + ubyte[] front(); + void popFront(); +} + +class SocketResponseOutputRange(size_t BufferSize) : ResponseOutputRange { + private SocketOutputRange!BufferSize outputRange; + + this(Socket socket) { + this.outputRange = SocketOutputRange!BufferSize(socket); + } + + void put(ubyte singleByte) { + outputRange.put(singleByte); + } + + void put(ubyte[] bytes) { + outputRange.put(bytes); + } + + void put(T)(const T value) { + outputRange.put!(T)(value); + } + + void flush() { + outputRange.flush(); + } +} + +class SocketRequestInputRange(size_t BufferSize) : RequestInputRange { + private SocketInputRange!BufferSize inputRange; + + this(Socket socket, bool initialRead = true) { + this.inputRange = SocketInputRange!BufferSize(socket, initialRead); + } + + bool empty() { + return inputRange.empty(); + } + + ubyte[] front() { + return inputRange.front(); + } + + void popFront() { + inputRange.popFront(); + } +} + +/** + * An output range that simply writes to an internal buffer, which is useful + * for inspecting the data written to an HTTP response, for example. + */ +class ArrayResponseOutputRange : ResponseOutputRange { + import std.array; + Appender!(ubyte[]) app; + + void put(ubyte singleByte) { + app ~= singleByte; + } + + void put(ubyte[] bytes) { + app ~= bytes; + } + + void put(T)(const T value) { + import std.bitmanip : nativeToBigEndian; + auto bytes = nativeToBigEndian(value); + app ~= bytes[0..T.sizeof]; + } + + void flush() { + // Do nothing. + } + + ubyte[] data() { + return app[]; + } +} + +// Test basic operations of the ArrayResponseOutputRange +unittest { + import std.bitmanip : bigEndianToNative; + + scope r = new ArrayResponseOutputRange(); + r.put(cast(ubyte) 1); + assert(r.data.length == 1); + assert(r.data[0] == 1); + r.put!ulong(42); + assert(r.data.length == ubyte.sizeof + ulong.sizeof, format!"%d"(r.data.length)); + + ubyte[ulong.sizeof] bytes = r.data[1..ulong.sizeof + ubyte.sizeof]; + ulong value = bigEndianToNative!ulong(bytes); + assert(value == 42); + + scope r2 = new ArrayResponseOutputRange(); + ubyte[] data = [1, 2, 3, 4, 5]; + r2.put(data); + assert(r2.data.length == data.length); + assert(r2.data == data); +} + +/** + * An input range that simply supplies data from an internal buffer, which is + * useful for validating HTTP request logic against pre-written requests, for + * example. + */ +class ArrayRequestInputRange : RequestInputRange { + ubyte[] data; + bool popped = false; + + this(ubyte[] data) { + this.data = data; + } + + bool empty() { + return !popped; + } + + ubyte[] front() { + if (popped) return []; + return data; + } + + void popFront() { + popped = true; + this.data = null; + } +} diff --git a/sub-packages/http-primitives/source/http_primitives/request.d b/sub-packages/http-primitives/source/http_primitives/request.d new file mode 100644 index 0000000..766dfe7 --- /dev/null +++ b/sub-packages/http-primitives/source/http_primitives/request.d @@ -0,0 +1,65 @@ +module http_primitives.request; + +import http_primitives.multivalue_map; +import http_primitives.ranges; + +import std.socket : Address; + +/** + * A struct describing the contents of an HTTP request. + */ +struct HttpRequest { + /** + * The HTTP method, or verb, which was requested. + */ + Method method = Method.GET; + + /** + * The URL that was requested. + */ + string url = ""; + + /** + * The HTTP version of this request. + */ + ubyte httpVersion = 1; + + /** + * A multi-valued map of headers that were provided to this request. + */ + StringMultiValueMap headers; + + /** + * A multi-valued map of query parameters that were provided to this + * request, as parsed from the request's URL. + */ + StringMultiValueMap queryParams; + + /** + * The remote address that this request came from. + */ + Address remoteAddress; + + /** + * The input range from which the request body can be read. + */ + RequestInputRange inputRange; +} + +/** + * Enumeration of all possible HTTP request methods as unsigned integer values + * for efficient logic. + * + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods + */ +enum Method : ushort { + GET = 1 << 0, + HEAD = 1 << 1, + POST = 1 << 2, + PUT = 1 << 3, + DELETE = 1 << 4, + CONNECT = 1 << 5, + OPTIONS = 1 << 6, + TRACE = 1 << 7, + PATCH = 1 << 8 +} diff --git a/sub-packages/http-primitives/source/http_primitives/response.d b/sub-packages/http-primitives/source/http_primitives/response.d new file mode 100644 index 0000000..d786361 --- /dev/null +++ b/sub-packages/http-primitives/source/http_primitives/response.d @@ -0,0 +1,124 @@ +module http_primitives.response; + +import http_primitives.multivalue_map; +import http_primitives.ranges; + +/** + * A struct describing the contents of an HTTP response. + */ +struct HttpResponse { + /** + * The status of this response. + */ + HttpResponseStatusInfo status = HttpStatus.OK; + + /** + * A multi-valued map of headers to send with this response. + */ + MultiValueMap!(string, string, false) headers; + + /** + * The output range to write the response to. + */ + ResponseOutputRange outputRange; + + /** + * A private flag indicating whether this response has written its status + * and headers. This is used to make sure they're only written once, no + * matter how many times the included "write..." functions are called. + * Use `response.isFlushed` to check the value. + */ + private bool statusAndHeadersWritten; +} + +/** + * A struct containing basic information about a response status. + */ +struct HttpResponseStatusInfo { + /** + * The integer status code for this response status. + */ + ushort code; + + /** + * A textual description of this response status. + */ + string text; +} + +/** + * An enum defining all valid HTTP response statuses: + * See here: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + */ +enum HttpStatus : HttpResponseStatusInfo { + // Information + CONTINUE = HttpResponseStatusInfo(100, "Continue"), + SWITCHING_PROTOCOLS = HttpResponseStatusInfo(101, "Switching Protocols"), + PROCESSING = HttpResponseStatusInfo(102, "Processing"), + EARLY_HINTS = HttpResponseStatusInfo(103, "Early Hints"), + + // Success + OK = HttpResponseStatusInfo(200, "OK"), + CREATED = HttpResponseStatusInfo(201, "Created"), + ACCEPTED = HttpResponseStatusInfo(202, "Accepted"), + NON_AUTHORITATIVE_INFORMATION = HttpResponseStatusInfo(203, "Non-Authoritative Information"), + NO_CONTENT = HttpResponseStatusInfo(204, "No Content"), + RESET_CONTENT = HttpResponseStatusInfo(205, "Reset Content"), + PARTIAL_CONTENT = HttpResponseStatusInfo(206, "Partial Content"), + MULTI_STATUS = HttpResponseStatusInfo(207, "Multi-Status"), + ALREADY_REPORTED = HttpResponseStatusInfo(208, "Already Reported"), + IM_USED = HttpResponseStatusInfo(226, "IM Used"), + + // Redirection + MULTIPLE_CHOICES = HttpResponseStatusInfo(300, "Multiple Choices"), + MOVED_PERMANENTLY = HttpResponseStatusInfo(301, "Moved Permanently"), + FOUND = HttpResponseStatusInfo(302, "Found"), + SEE_OTHER = HttpResponseStatusInfo(303, "See Other"), + NOT_MODIFIED = HttpResponseStatusInfo(304, "Not Modified"), + TEMPORARY_REDIRECT = HttpResponseStatusInfo(307, "Temporary Redirect"), + PERMANENT_REDIRECT = HttpResponseStatusInfo(308, "Permanent Redirect"), + + // Client error + BAD_REQUEST = HttpResponseStatusInfo(400, "Bad Request"), + UNAUTHORIZED = HttpResponseStatusInfo(401, "Unauthorized"), + PAYMENT_REQUIRED = HttpResponseStatusInfo(402, "Payment Required"), + FORBIDDEN = HttpResponseStatusInfo(403, "Forbidden"), + NOT_FOUND = HttpResponseStatusInfo(404, "Not Found"), + METHOD_NOT_ALLOWED = HttpResponseStatusInfo(405, "Method Not Allowed"), + NOT_ACCEPTABLE = HttpResponseStatusInfo(406, "Not Acceptable"), + PROXY_AUTHENTICATION_REQUIRED = HttpResponseStatusInfo(407, "Proxy Authentication Required"), + REQUEST_TIMEOUT = HttpResponseStatusInfo(408, "Request Timeout"), + CONFLICT = HttpResponseStatusInfo(409, "Conflict"), + GONE = HttpResponseStatusInfo(410, "Gone"), + LENGTH_REQUIRED = HttpResponseStatusInfo(411, "Length Required"), + PRECONDITION_FAILED = HttpResponseStatusInfo(412, "Precondition Failed"), + PAYLOAD_TOO_LARGE = HttpResponseStatusInfo(413, "Payload Too Large"), + URI_TOO_LONG = HttpResponseStatusInfo(414, "URI Too Long"), + UNSUPPORTED_MEDIA_TYPE = HttpResponseStatusInfo(415, "Unsupported Media Type"), + RANGE_NOT_SATISFIABLE = HttpResponseStatusInfo(416, "Range Not Satisfiable"), + EXPECTATION_FAILED = HttpResponseStatusInfo(417, "Expectation Failed"), + IM_A_TEAPOT = HttpResponseStatusInfo(418, "I'm a teapot"), + MISDIRECTED_REQUEST = HttpResponseStatusInfo(421, "Misdirected Request"), + UNPROCESSABLE_CONTENT = HttpResponseStatusInfo(422, "Unprocessable Content"), + LOCKED = HttpResponseStatusInfo(423, "Locked"), + FAILED_DEPENDENCY = HttpResponseStatusInfo(424, "Failed Dependency"), + TOO_EARLY = HttpResponseStatusInfo(425, "Too Early"), + UPGRADE_REQUIRED = HttpResponseStatusInfo(426, "Upgrade Required"), + PRECONDITION_REQUIRED = HttpResponseStatusInfo(428, "Precondition Required"), + TOO_MANY_REQUESTS = HttpResponseStatusInfo(429, "Too Many Requests"), + REQUEST_HEADER_FIELDS_TOO_LARGE = HttpResponseStatusInfo(431, "Request Header Fields Too Large"), + UNAVAILABLE_FOR_LEGAL_REASONS = HttpResponseStatusInfo(451, "Unavailable For Legal Reasons"), + + // Server error + INTERNAL_SERVER_ERROR = HttpResponseStatusInfo(500, "Internal Server Error"), + NOT_IMPLEMENTED = HttpResponseStatusInfo(501, "Not Implemented"), + BAD_GATEWAY = HttpResponseStatusInfo(502, "Bad Gateway"), + SERVICE_UNAVAILABLE = HttpResponseStatusInfo(503, "Service Unavailable"), + GATEWAY_TIMEOUT = HttpResponseStatusInfo(504, "Gateway Timeout"), + HTTP_VERSION_NOT_SUPPORTED = HttpResponseStatusInfo(505, "HTTP Version Not Supported"), + VARIANT_ALSO_NEGOTIATES = HttpResponseStatusInfo(506, "Variant Also Negotiates"), + INSUFFICIENT_STORAGE = HttpResponseStatusInfo(507, "Insufficient Storage"), + LOOP_DETECTED = HttpResponseStatusInfo(508, "Loop Detected"), + NOT_EXTENDED = HttpResponseStatusInfo(510, "Not Extended"), + NETWORK_AUTHENTICATION_REQUIRED = HttpResponseStatusInfo(511, "Network Authentication Required") +}