diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e6b5c40..5c42f26 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -96,22 +96,10 @@ jobs: with: compiler: ${{ matrix.compiler }} - - name: "Example: file-upload" - working-directory: examples/file-upload - run: dub -q build --single server.d - - - name: "Example: handler-testing" - working-directory: examples/handler-testing - run: dub test --single handler.d - - - name: "Example: multiple-handlers" - working-directory: examples/multiple-handlers - run: dub -q build - - - name: "Example: single-file-server" - working-directory: examples/single-file-server - run: dub -q build --single hello.d - - - name: "Example: static-content-server" - working-directory: examples/static-content-server - run: dub -q build --single content_server.d + - name: Test Examples + working-directory: examples + run: rdmd runner.d test + + - name: Clean Examples + working-directory: examples + run: rdmd runner.d clean diff --git a/README.md b/README.md index 99e355a..719205c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # handy-httpd +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/andrewlalis/handy-httpd/testing.yml?branch=main&style=flat-square&logo=github&label=tests) +![GitHub issues](https://img.shields.io/github/issues/andrewlalis/handy-httpd?style=flat-square) +![DUB Downloads](https://img.shields.io/dub/dt/handy-httpd?style=flat-square&logo=d&logoColor=%23B03931) +![GitHub Tag](https://img.shields.io/github/v/tag/andrewlalis/handy-httpd?style=flat-square&label=version&color=%23B03931) + An extremely lightweight HTTP server for the [D programming language](https://dlang.org/). ## Features - HTTP/1.1 - [Web Sockets](https://andrewlalis.github.io/handy-httpd/guide/handlers/websocket-handler.html) -- Worker pool for request handling - [Simple configuration](https://andrewlalis.github.io/handy-httpd/guide/configuration.html) -- High performance +- High performance with interchangeable request processors - Beginner friendly - Extensible with custom handlers, exception handlers, and filters - [Well-documented](https://andrewlalis.github.io/handy-httpd/) @@ -17,7 +21,7 @@ An extremely lightweight HTTP server for the [D programming language](https://dl - Apply filters before and after handling requests with the [FilteredHandler](https://andrewlalis.github.io/handy-httpd/guide/handlers/filtered-handler.html) - Handle complex URL paths, including path parameters and wildcards, with the [PathHandler](https://andrewlalis.github.io/handy-httpd/guide/handlers/path-handler.html) -## Important Links +## Links - [Documentation](https://andrewlalis.github.io/handy-httpd/) - [Examples](https://github.com/andrewlalis/handy-httpd/tree/main/examples) - [Dub Package Page](https://code.dlang.org/packages/handy-httpd) @@ -30,11 +34,7 @@ import handy_httpd; void main() { new HttpServer((ref ctx) { - if (ctx.request.url == "/hello") { - ctx.response.writeBodyString("Hello world!"); - } else { - ctx.response.setStatus(HttpStatus.NOT_FOUND); - } + ctx.response.writeBodyString("Hello world!"); }).start(); } ``` diff --git a/docs/src/guide/configuration.md b/docs/src/guide/configuration.md index 30e50c0..9966f01 100644 --- a/docs/src/guide/configuration.md +++ b/docs/src/guide/configuration.md @@ -89,5 +89,5 @@ This interval shouldn't need to be very small, unless a high percentage of your ### `enableWebSockets` | Type | Default Value | |--- |--- | -| `bool` | `true` | +| `bool` | `false` | Whether to enable websocket functionality for the server. If set to true, starting the server will also start an additional thread that handles websocket traffic in a nonblocking fashion. diff --git a/docs/src/guide/handlers/path-delegating-handler.md b/docs/src/guide/handlers/path-delegating-handler.md deleted file mode 100644 index e1a4f82..0000000 --- a/docs/src/guide/handlers/path-delegating-handler.md +++ /dev/null @@ -1,78 +0,0 @@ -# Path Delegating Handler - -**⚠️ This handler is deprecated in favor of the [PathHandler](./path-handler.md). It will no longer receive any updates, and you should consider switching over to the PathHandler for improved performance and support.** - -As you may have read in [Handling Requests](./handling-requests.md), Handy-Httpd offers a pre-made [PathDelegatingHandler](ddoc-handy_httpd.handlers.path_delegating_handler.PathDelegatingHandler) that can match HTTP methods and URLs to specific handlers; a common use case for web servers. - -A PathDelegatingHandler is an implementation of [HttpRequestHandler](ddoc-handy_httpd.components.handler.HttpRequestHandler) that will _delegate_ incoming requests to _other_ handlers based on the request's HTTP method and URL. Handlers are registered with the PathDelegatingHandler via one of the overloaded `addMapping` methods. - -For example, suppose we have a handler named `userHandler` that we want to invoke on **GET** requests to URLs like `/users/{userId:int}`. - -```d -auto userHandler = getUserHandler(); -auto pathHandler = new PathDelegatingHandler(); -pathHandler.addMapping(Method.GET, "/users/{userId:int}", userHandler); -new HttpServer(pathHandler).start(); -``` - -## Path Patterns - -In our example, we used the pattern `/users/{userId:int}`. This is using the PathDelegatingHandler's path pattern specification, which closely resembles so-called "Ant-style" path matchers. - -A path pattern is a string that describes a pattern for matching URLs, using the concept of _segments_, where a _segment_ is a section of the URL path. If our URL is `/a/b/c`, its segments are `a`, `b`, and `c`. - -The following rules define the path pattern syntax: - -- Literal URLs are matched exactly. -- `/*` matches any single segment in the URL. -- `/**` matches zero or more segments in the URL. -- `?` matches any single character in the URL. -- `{varName[:type]}` matches a path variable (optionally of a certain type). - -The easiest way to understand how these rules apply are with some examples: - -``` -Pattern Matches Doesn't Match -------------------------------------------------------------- -/users /users /users/data - -/users/* /users/data /users - /users/settings /users/data/yes - -/users/** /users /user - /users/data - /users/data/settings - -/data? /datax /data - /datay /dataxyz - /dataz - -/users/{userId} /users/123 /users - /users/1a2b /users/123/data - -/users/{userId:int} /users/123 /users/a - /users/42 /users/35.2 - /users/-35 - -/users/{id:uint} /users/123 /users/-2 - /users/42 - -/users/{name:string} /users/andrew /users - /users/123 -``` - -### Parameter Types - -The following parameter types are built-in to the handler's path pattern parser: - -| Name | Description | Regex | -| --- | --- | --- | -| int | A signed integer number. | `-?[0-9]+` | -| uint | An unsigned integer number. | `[0-9]+` | -| string | A series of characters, excluding whitespaces. | `\w+` | -| uuid | A [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) string. |Too long | - -If you'd like a different pattern, you can use a literal regex instead. - -Note that by specifying a type for a path parameter, you guarantee that you can safely call `ctx.request.getPathParamAs!uint("id")` from your handler, and rest assured that it has a value. - diff --git a/docs/src/guide/handling-requests.md b/docs/src/guide/handling-requests.md index 359aef3..a0e2637 100644 --- a/docs/src/guide/handling-requests.md +++ b/docs/src/guide/handling-requests.md @@ -40,9 +40,9 @@ Each request also contains a `remoteAddress`, which contains the remote socket a #### Headers and Parameters -The request's headers are available via the `headers` associative array, where each header name is mapped to a single string value. There are no guarantees about which headers may be present, nor their type. It's generally up to the handler to figure out how to deal with them. +The request's headers are available via the [headers](ddoc-handy_httpd.components.request.HttpRequest.headers) *[multivalued map](ddoc-handy_httpd.components.multivalue_map.MultiValueMap)*, where each header name is mapped to one or more string values. There are no guarantees about which headers may be present, and it's generally up to the handler to do this. -Similarly, the request's `queryParams` is a list of [QueryParam](ddoc-handy_httpd.components.form_urlencoded.QueryParam)s that were parsed from the URL. For example, in `http://example.com/?x=5`, Handy-Httpd would provide a request whose params are `[QueryParam("x", "5")]`. Like the headers, no guarantee is made about what params are present, or what type they are. However, you can use the [getParamAs](ddoc-handy_httpd.components.request.HttpRequest.getParamAs) function as a safe way to get a parameter as a specified type, or fallback to a default. +Similarly, the request's [queryParams](ddoc-handy_httpd.components.request.HttpRequest.queryParams) is a *[multivalued map](ddoc-handy_httpd.components.multivalue_map.MultiValueMap)* containing all query parameters that were parsed from the URL. For example, in `http://example.com/?x=5`, Handy-Httpd would provide a request whose params are `["x": ["5"]]`. Like the headers, no guarantee is made about what params are present, or what type they are. However, you can use the [getParamAs](ddoc-handy_httpd.components.request.HttpRequest.getParamAs) function as a safe way to get a parameter as a specified type, or fallback to a default. ```d void handle(ref HttpRequestContext ctx) { @@ -53,11 +53,9 @@ void handle(ref HttpRequestContext ctx) { In the above snippet, if the user requests `https://your-site.com/search?page=24`, then `page` will be set to 24. However, if a user requests `https://your-site.com/search?page=blah`, or doesn't provide a page at all, `page` will be set to `1`. -> ⚠️ Previously, query parameters were accessed through [HttpRequest.params](ddoc-handy_httpd.components.request.HttpRequest.params), but this is deprecated in favor of the query params. This is because the old params implementation didn't accurately follow the official specification; specifically, there can be multiple query parameters with the same name, but different values. An associative string array can't represent that type of data. - #### Path Parameters -If a request is handled by a [PathHandler](handlers/path-handler.md) *(or the now-deprecated [PathDelegatingHandler](handlers/path-delegating-handler.md))*, then its `pathParams` associative array will be populated with any path parameters that were parsed from the URL. +If a request is handled by a [PathHandler](handlers/path-handler.md), then its [pathParams](ddoc-handy_httpd.components.request.HttpRequest.pathParams) associative array will be populated with any path parameters that were parsed from the URL. The easiest way to understand this behavior is through an example. Suppose we define our top-level PathHandler with the following mapping, so that a `userSettingsHandler` will handle requests to that endpoint: @@ -91,7 +89,7 @@ Some requests that your server receives may include a body, which is any content | [readBodyAsJson](ddoc-handy_httpd.components.request.HttpRequest.readBodyAsJson) | Reads the entire request body as a [JSONValue](https://dlang.org/phobos/std_json.html#.JSONValue). | | [readBodyToFile](ddoc-handy_httpd.components.request.HttpRequest.readBodyToFile) | Reads the entire request body and writes it to a given file. | -Additionally, you can import the [multipart](ddoc-handy_httpd.components.multipart) module to expose the [readBodyAsMultipartFormData](ddoc-handy_httpd.components.multipart.readBodyAsMultipartFormData) function, which is commonly used for handling file uploads. +Additionally, you can use the [multipart](ddoc-handy_httpd.components.multipart) module's [readBodyAsMultipartFormData](ddoc-handy_httpd.components.multipart.readBodyAsMultipartFormData) function, which is commonly used for handling file uploads. > ⚠️ While Handy-Httpd doesn't force you to limit the amount of data you read, please be careful when reading an entire request body at once, like with `readBodyAsString`. This will load the entire request body into memory, and **will** crash your program if the body is too large. diff --git a/examples/.gitignore b/examples/.gitignore index 9b0eb7e..5cda068 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1,3 +1,8 @@ -single-file-server/hello -static-content-server/content_server -*.log \ No newline at end of file +# Ignore all artifacts that might be generated. +* + +# Except D source files and other stuff that's not auto-generated. +!*.d +!*.md +!*.sh +!*.json diff --git a/examples/README.md b/examples/README.md index bd79038..a22d5fc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,4 +3,24 @@ Inside this directory, you'll find a series of examples which show how Handy-htt Single-file scripts annotated with a shebang can be run in a unix command-line with `./`. On other systems, you should be able to do `dub run --single `. -Otherwise, you'll find a `dub.json` file, indicating a dub project, so you can just do `dub run` for those cases. +| Example | Description | +|---|---| +| hello-world | Basic example which shows how to configure and start a server. | +| using-headers | Shows you how to inspect, list, and get the headers from a request. | +| path-handler | Shows you how to use the `PathHandler` to route requests to handlers based on their path, and consume path variables from the request's URL. | +| file-upload | Demonstrates file uploads using multipart/form-data encoding. | +| handler-testing | Shows how you can write unit tests for your request handler functions or classes. | +| static-content-server | Shows how you can use the `FileResolvingHandler` to serve static files from a directory. | +| websocket | Shows how you can enable websocket support and use the `WebSocketHandler` to send and receive websocket messages. | + + +## Runner Script + +A runner script is provided for your convenience. Compile it with `dmd runner.d`, or run directly with `./runner.d`. You can: +- List all examples: `./runner list` +- Clean the examples directory and remove compiled binaries: `./runner clean` +- Select an example to run from a list: `./runner run` +- Run a specific example: `./runner run ` +- Run all examples at the same time: `./runner run all` + +> Note: When running all examples at the same time, servers will each be given a different port number, starting at 8080. diff --git a/examples/file-upload.d b/examples/file-upload.d new file mode 100755 index 0000000..d040bde --- /dev/null +++ b/examples/file-upload.d @@ -0,0 +1,68 @@ +#!/usr/bin/env dub +/+ dub.sdl: + dependency "handy-httpd" path="../" ++/ + +/** + * This example shows how you can manage basic file-upload mechanics using + * an HTML form and multipart/form-data encoding. In this example, we show a + * simple form, and when the user uploads some files, a summary of the files + * is shown. + */ +module examples.file_upload; + +import handy_httpd; +import slf4d; +import handy_httpd.handlers.path_handler; + +const indexContent = ` + + +

Upload a file!

+
+ + + + +
+ + +`; + +void main(string[] args) { + ServerConfig cfg = ServerConfig.defaultValues; + if (args.length > 1) { + import std.conv; + cfg.port = args[1].to!ushort; + } + new HttpServer(new PathHandler() + .addMapping(Method.GET, "/**", &serveIndex) // Show the index content to any request. + .addMapping(Method.POST, "/upload", &handleUpload), // And handle uploads only on POST requests to /upload. + cfg + ).start(); +} + +void serveIndex(ref HttpRequestContext ctx) { + ctx.response.writeBodyString(indexContent, "text/html; charset=utf-8"); +} + +void handleUpload(ref HttpRequestContext ctx) { + MultipartFormData data = readBodyAsMultipartFormData(ctx.request); + string response = "File Upload Summary:\n\n"; + foreach (i, MultipartElement element; data.elements) { + import std.format; + string filename = element.filename.isNull ? "NULL" : element.filename.get(); + response ~= format! + "Multipart Element %d of %d:\n\tFilename: %s\n\tSize: %d\n" + ( + i + 1, + data.elements.length, + filename, + element.content.length + ); + foreach (string header, string value; element.headers) { + response ~= format!"\t\tHeader \"%s\": \"%s\"\n"(header, value); + } + } + ctx.response.writeBodyString(response); +} diff --git a/examples/file-upload/.gitignore b/examples/file-upload/.gitignore deleted file mode 100644 index bbc2d78..0000000 --- a/examples/file-upload/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -latest-upload -server diff --git a/examples/file-upload/README.md b/examples/file-upload/README.md deleted file mode 100644 index 90dd638..0000000 --- a/examples/file-upload/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# File Upload Example - -This example showcases a simple file upload server with two main endpoints: one for requesting an HTML index page with an upload form, and one for actually sending the form's content. - -To start the server, run `dub run --single server.d` diff --git a/examples/file-upload/server.d b/examples/file-upload/server.d deleted file mode 100755 index 13a93da..0000000 --- a/examples/file-upload/server.d +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env dub -/+ dub.sdl: - dependency "handy-httpd" path="../../" -+/ -import handy_httpd; -import slf4d; -import slf4d.default_provider; -import handy_httpd.handlers.path_handler; - -const indexContent = ` - - -

Upload a file!

-
- - - - -
- - -`; - -void main() { - auto provider = new shared DefaultProvider(true, Levels.INFO); - configureLoggingProvider(provider); - - ServerConfig cfg = ServerConfig.defaultValues(); - cfg.workerPoolSize = 3; - cfg.port = 8080; - PathHandler handler = new PathHandler() - .addMapping(Method.GET, "/**", &serveIndex) - .addMapping(Method.POST, "/upload", &handleUpload); - info("Starting file-upload example server."); - new HttpServer(handler, cfg).start(); -} - -void serveIndex(ref HttpRequestContext ctx) { - ctx.response.writeBodyString(indexContent, "text/html; charset=utf-8"); -} - -void handleUpload(ref HttpRequestContext ctx) { - info("User uploaded a file!"); - MultipartFormData data = readBodyAsMultipartFormData(ctx.request); - infoF!"Read multipart data with %d elements."(data.elements.length); - foreach (MultipartElement element; data.elements) { - infoF!"Element name: %s, filename: %s, headers: %s, content-length: %s"( - element.name, - element.filename.isNull ? "Null" : element.filename.get(), - element.headers, - element.content.length - ); - } -} diff --git a/examples/handler-testing/handler.d b/examples/handler-testing.d similarity index 88% rename from examples/handler-testing/handler.d rename to examples/handler-testing.d index b053d83..3c361c1 100644 --- a/examples/handler-testing/handler.d +++ b/examples/handler-testing.d @@ -1,7 +1,12 @@ /+ dub.sdl: - dependency "handy-httpd" path="../../" + dependency "handy-httpd" path="../" +/ -module handler; + +/** + * An example that shows how you can unit-test a request handler. You can run + * this example by calling "dub test --single handler-testing.d". + */ +module examples.handler_testing; import handy_httpd; import std.conv : to, ConvException; diff --git a/examples/handler-testing/README.md b/examples/handler-testing/README.md deleted file mode 100644 index 8b9c116..0000000 --- a/examples/handler-testing/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Handler Testing Example - -This example shows how you can write unit tests for an `HttpRequestHandler`. - -Run the tests with this command (or run `./run-test.sh`): -```shell -dub test --single handler.d -``` diff --git a/examples/handler-testing/run-tests.sh b/examples/handler-testing/run-tests.sh deleted file mode 100755 index dc1cb12..0000000 --- a/examples/handler-testing/run-tests.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -dub test --single handler.d diff --git a/examples/single-file-server/hello.d b/examples/hello-world.d similarity index 51% rename from examples/single-file-server/hello.d rename to examples/hello-world.d index dafed69..6f271cc 100755 --- a/examples/single-file-server/hello.d +++ b/examples/hello-world.d @@ -1,20 +1,23 @@ #!/usr/bin/env dub /+ dub.sdl: - dependency "handy-httpd" path="../../" + dependency "handy-httpd" path="../" +/ + +/** + * A basic example that shows you how to start your server and deal with + * incoming requests. + */ +module examples.hello_world; + import handy_httpd; import slf4d; -void main() { - // First we set up our server's configuration, using mostly default values, - // but we'll tweak a few settings, and to be extra clear, we explicitly set - // the port to 8080 (even though that's the default). - ServerConfig cfg = ServerConfig.defaultValues(); - cfg.workerPoolSize = 5; - cfg.port = 8080; - - // Now we construct a new instance of the HttpServer class, and provide it - // a lambda function to use when handling requests. +void main(string[] args) { + ServerConfig cfg = ServerConfig.defaultValues; + if (args.length > 1) { + import std.conv; + cfg.port = args[1].to!ushort; + } new HttpServer((ref ctx) { // We can inspect the request's URL directly like so: if (ctx.request.url == "/stop") { @@ -26,5 +29,5 @@ void main() { } else { ctx.response.setStatus(HttpStatus.NOT_FOUND); } - }, cfg).start(); // Calling start actually start's the server's main loop. + }, cfg).start(); // Calling start actually starts the server's main loop. } diff --git a/examples/multiple-handlers/.gitignore b/examples/multiple-handlers/.gitignore deleted file mode 100644 index 3228e71..0000000 --- a/examples/multiple-handlers/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -.dub -docs.json -__dummy.html -docs/ -/multiple-handlers -multiple-handlers.so -multiple-handlers.dylib -multiple-handlers.dll -multiple-handlers.a -multiple-handlers.lib -multiple-handlers-test-* -*.exe -*.o -*.obj -*.lst diff --git a/examples/multiple-handlers/README.md b/examples/multiple-handlers/README.md deleted file mode 100644 index ca5233b..0000000 --- a/examples/multiple-handlers/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Multiple Handlers Example - -This example shows how you can use a `PathDelegatingHandler` to configure your server to handle multiple different paths, even with path parameters. - -Run this example with `dub run`. diff --git a/examples/multiple-handlers/dub.json b/examples/multiple-handlers/dub.json deleted file mode 100644 index b492ffd..0000000 --- a/examples/multiple-handlers/dub.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "authors": [ - "Andrew Lalis" - ], - "copyright": "Copyright © 2023, Andrew Lalis", - "description": "A minimal D application.", - "license": "proprietary", - "name": "multiple-handlers", - "dependencies": { - "handy-httpd": { - "path": "../../" - } - } -} \ No newline at end of file diff --git a/examples/multiple-handlers/dub.selections.json b/examples/multiple-handlers/dub.selections.json deleted file mode 100644 index c5465b3..0000000 --- a/examples/multiple-handlers/dub.selections.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "fileVersion": 1, - "versions": { - "handy-httpd": {"path":"../../"}, - "httparsed": "1.2.1", - "path-matcher": "1.1.3", - "slf4d": "2.4.1", - "streams": "3.4.3" - } -} diff --git a/examples/multiple-handlers/source/app.d b/examples/multiple-handlers/source/app.d deleted file mode 100644 index c7676cb..0000000 --- a/examples/multiple-handlers/source/app.d +++ /dev/null @@ -1,32 +0,0 @@ -import handy_httpd; -import handy_httpd.handlers.path_handler; -import slf4d; - -void main() { - auto pathHandler = new PathHandler() - .addMapping(Method.GET, "/users", &handleUsers) - .addMapping(Method.GET, "/users/:userId:uint", &handleUser) - .addMapping(Method.GET, "/items", &handleItems) - .addMapping("/error", &handleWithError); - auto server = new HttpServer(pathHandler); - server.start(); -} - -void handleUsers(ref HttpRequestContext ctx) { - ctx.response.writeBodyString("You're on the /users page."); -} - -void handleUser(ref HttpRequestContext ctx) { - ulong userId = ctx.request.getPathParamAs!ulong("userId"); - infoF!"User %d visited the page."(userId); - ctx.response.writeBodyString("Hello user!"); -} - -void handleItems(ref HttpRequestContext ctx) { - ctx.response.writeBodyString("You're on the /items page."); -} - -void handleWithError(ref HttpRequestContext ctx) { - ctx.response.writeBodyString("This worker has an error."); - throw new Error("Oh no"); -} diff --git a/examples/path-handler.d b/examples/path-handler.d new file mode 100644 index 0000000..3944bc7 --- /dev/null +++ b/examples/path-handler.d @@ -0,0 +1,95 @@ +#!/usr/bin/env dub +/+ dub.sdl: + dependency "handy-httpd" path="../" ++/ + +/** + * This example shows you how to use the `PathHandler` to route requests to + * specific handlers based on their path, and includes examples which use + * path variables to extract information from parts of the request's URL path. + * + * In this example, we'll build a simple API that stores a list of names, and + * use it to showcase the features of the PathHandler. + * + * To get the list of names: `curl http://localhost:8080/names` + * + * To add a name to the list: `curl -X POST http://localhost:8080/names?name=john` + * + * To get a name by its index: `curl http://localhost:8080/names/0` + */ +module examples.path_handler; + +import handy_httpd; +import handy_httpd.handlers.path_handler; +import std.json; + +/// The global list of names that this example uses. +__gshared string[] names = []; + +void main(string[] args) { + ServerConfig cfg = ServerConfig.defaultValues; + if (args.length > 1) { + import std.conv; + cfg.port = args[1].to!ushort; + } + + // We'll use the PathHandler as our server's "root" handler. + // Handy-Httpd uses composition to add functionality to your server. + PathHandler pathHandler = new PathHandler(); + + // We can add mappings to handler functions or HttpRequestHandler instances. + pathHandler.addMapping(Method.GET, "/names", &getNames); + pathHandler.addMapping(Method.POST, "/names", &postName); + + // We can also specify a path variable, which will be extracted by the + // PathHandler if a request matches this pattern. + // Notice how we annotate it as a ulong; only unsigned long integers are + // accepted. "/names/abc" will not match, for example. + pathHandler.addMapping(Method.GET, "/names/:idx:ulong", &getName); + + new HttpServer(pathHandler, cfg).start(); +} + +/** + * Shows all names as a JSON array of strings. + * Params: + * ctx = The request context. + */ +void getNames(ref HttpRequestContext ctx) { + JSONValue response = JSONValue(string[].init); + foreach (name; names) { + response.array ~= JSONValue(name); + } + ctx.response.writeBodyString(response.toString(), "application/json"); +} + +/** + * Adds a name to the global list of names, if one is provided as a query + * parameter with the key "name". + * Params: + * ctx = The request context. + */ +void postName(ref HttpRequestContext ctx) { + string name = ctx.request.queryParams.getFirst("name").orElse(null); + if (name is null || name.length == 0) { + ctx.response.setStatus(HttpStatus.BAD_REQUEST); + ctx.response.writeBodyString("Missing name."); + return; + } + names ~= name; +} + +/** + * Gets a specific name by its index in the global list of names. + * Params: + * ctx = The request context. + */ +void getName(ref HttpRequestContext ctx) { + ulong idx = ctx.request.getPathParamAs!ulong("idx"); + if (idx >= names.length) { + ctx.response.setStatus(HttpStatus.NOT_FOUND); + ctx.response.writeBodyString("No name with that index was found."); + return; + } + ctx.response.writeBodyString(names[idx]); +} diff --git a/examples/runner.d b/examples/runner.d old mode 100644 new mode 100755 index fed3b6c..4641c28 --- a/examples/runner.d +++ b/examples/runner.d @@ -1,81 +1,231 @@ #!/usr/bin/env rdmd +module examples.runner; + import std.stdio; import std.process; +import std.conv; import std.path; import std.file; +import std.string; import std.algorithm; import std.typecons; import std.array; import core.thread; +interface Example { + string name() const; + Pid run(string[] args) const; + Pid test() const; + string[] requiredFiles() const; +} + +class DubSingleFileExample : Example { + private string workingDir; + private string filename; + + this(string workingDir, string filename) { + this.workingDir = workingDir; + this.filename = filename; + } + + this(string filename) { + this(".", filename); + } + + string name() const { + if (workingDir != ".") return workingDir; + return filename[0..$-2]; + } + + Pid run(string[] args) const { + string[] cmd = ["dub", "run", "--single", filename]; + if (args.length > 0) { + cmd ~= "--"; + cmd ~= args; + } + return spawnProcess( + cmd, + std.stdio.stdin, + std.stdio.stdout, + std.stdio.stderr, + null, + Config.none, + workingDir + ); + } + + Pid test() const { + return spawnProcess( + ["dub", "build", "--single", filename], + std.stdio.stdin, + std.stdio.stdout, + std.stdio.stderr, + null, + Config.none, + workingDir + ); + } + + string[] requiredFiles() const { + if (workingDir == ".") { + return [filename]; + } else { + return [workingDir]; + } + } +} + +class DubSingleFileUnitTestExample : Example { + private string filename; + + this(string filename) { + this.filename = filename; + } + + string name() const { + return filename[0..$-2]; + } + + Pid run(string[] args) const { + return spawnProcess( + ["dub", "test", "--single", filename] ~ args + ); + } + + Pid test() const { + return run([]); + } + + string[] requiredFiles() const { + return [filename]; + } +} + +const Example[] EXAMPLES = [ + new DubSingleFileExample("hello-world.d"), + new DubSingleFileExample("file-upload.d"), + new DubSingleFileExample("using-headers.d"), + new DubSingleFileExample("path-handler.d"), + new DubSingleFileExample("static-content-server", "content_server.d"), + new DubSingleFileExample("websocket", "server.d"), + new DubSingleFileUnitTestExample("handler-testing.d") +]; + int main(string[] args) { - string[] exampleDirs = []; - foreach (entry; dirEntries(".", SpanMode.shallow, false)) { - if (entry.isDir) exampleDirs ~= entry.name; - } - - Thread[] processThreads = []; - foreach (dir; exampleDirs) { - auto nullablePid = runExample(dir); - if (!nullablePid.isNull) { - Thread processThread = new Thread(() { - Pid pid = nullablePid.get(); - int result = pid.wait(); - writefln!"Example %s exited with code %d."(dir, result); - }); - processThread.start(); - processThreads ~= processThread; + if (args.length > 1 && toLower(args[1]) == "clean") { + return cleanExamples(); + } else if (args.length > 1 && toLower(args[1]) == "list") { + writefln!"The following %d examples are available:"(EXAMPLES.length); + foreach (example; EXAMPLES) { + writefln!" - %s"(example.name); } + return 0; + } else if (args.length > 1 && toLower(args[1]) == "run") { + return runExamples(args[2..$]); + } else if (args.length > 1 && toLower(args[1]) == "test") { + return testExamples(); } + writeln("Nothing to run."); + return 0; +} - foreach (thread; processThreads) { - thread.join(); +int cleanExamples() { + string currentPath = getcwd(); + string currentDir = baseName(currentPath); + if (currentDir != "examples") { + stderr.writeln("Not in the examples directory."); + return 1; } + + foreach (DirEntry entry; dirEntries(currentPath, SpanMode.shallow, false)) { + string filename = baseName(entry.name); + if (shouldRemove(filename)) { + if (entry.isFile) { + std.file.remove(entry.name); + } else { + std.file.rmdirRecurse(entry.name); + } + } + } + return 0; } -Nullable!Pid runExample(string dir) { - writefln!"Running example: %s"(dir); - // Prepare new standard streams for the example program. - File newStdout = File(buildPath(dir, "stdout.log"), "w"); - File newStderr = File(buildPath(dir, "stderr.log"), "w"); - File newStdin = File.tmpfile(); - if (exists(buildPath(dir, "dub.json"))) { - // Run normal dub project. - Nullable!Pid result; - result = spawnProcess( - ["dub", "run"], - newStdin, - newStdout, - newStderr, - null, - Config.none, - dir - ); - return result; +bool shouldRemove(string filename) { + bool required = false; + foreach (example; EXAMPLES) { + if (canFind(example.requiredFiles, filename)) { + required = true; + break; + } + } + if (!required) { + return filename != ".gitignore" && + filename != "runner" && + filename != "runner.exe" && + !endsWith(filename, ".d") && + !endsWith(filename, ".md"); + } + return false; +} + +int runExamples(string[] args) { + if (args.length > 0) { + string exampleName = strip(toLower(args[0])); + if (exampleName == "all") { + ushort port = 8080; + Pid[] pids = []; + foreach (example; EXAMPLES) { + if (cast(DubSingleFileUnitTestExample) example) { + pids ~= example.run([]); + } else { + pids ~= example.run([port.to!string]); + port++; + } + } + foreach (pid; pids) { + pid.wait(); + } + return 0; + } + foreach (example; EXAMPLES) { + if (example.name == exampleName) { + writefln!"Running example: %s"(example.name); + return example.run(args[1..$]).wait(); + } + } + stderr.writefln! + "\"%s\" does not refer to any known example. Use the \"list\" command to see available examples." + (exampleName); + return 1; } else { - // Run single-file project. - string executableFile = null; - foreach (entry; dirEntries(dir, SpanMode.shallow, false)) { - if (entry.name.endsWith(".d")) { - executableFile = entry.name; - break; + writeln("Select one of the examples below to run:"); + foreach (i, example; EXAMPLES) { + writefln!"[%d]\t%s"(i + 1, example.name); + } + string input = readln().strip(); + try { + uint idx = input.to!uint; + if (idx < 1 || idx > EXAMPLES.length) { + stderr.writefln!"%d is an invalid example number."(idx); + return 1; } + writefln!"Running example: %s"(EXAMPLES[idx - 1].name); + return EXAMPLES[idx - 1].run([]).wait(); + } catch (ConvException e) { + stderr.writefln!"\"%s\" is not a number."(input); + return 1; } - if (executableFile !is null) { - Nullable!Pid result; - result = spawnProcess( - ["dub", "run", "--single", baseName(executableFile)], - newStdin, - newStdout, - newStderr, - null, - Config.none, - dir - ); - return result; - } else { - return Nullable!Pid.init; + } +} + +int testExamples() { + foreach (example; EXAMPLES) { + int exitCode = example.test().wait(); + if (exitCode != 0) { + writefln!"Example %s failed with exit code %d."(example.name, exitCode); + return exitCode; } } -} \ No newline at end of file + return 0; +} diff --git a/examples/single-file-server/README.md b/examples/single-file-server/README.md deleted file mode 100644 index 8810084..0000000 --- a/examples/single-file-server/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Single File Server Example - -This example shows what a minimalistic single-file server could look like, including a shell shebang, inline dub.sdl declaration, and a simple HttpRequestHandler implementation that does its own URL logic. - -Run this example with `dub run --single hello.d` diff --git a/examples/static-content-server/content_server.d b/examples/static-content-server/content_server.d index a7b1e10..0bc3856 100755 --- a/examples/static-content-server/content_server.d +++ b/examples/static-content-server/content_server.d @@ -5,6 +5,11 @@ import handy_httpd; import handy_httpd.handlers.file_resolving_handler; -void main() { - new HttpServer(new FileResolvingHandler("content")).start(); +void main(string[] args) { + ServerConfig cfg = ServerConfig.defaultValues; + if (args.length > 1) { + import std.conv; + cfg.port = args[1].to!ushort; + } + new HttpServer(new FileResolvingHandler("content"), cfg).start(); } diff --git a/examples/using-headers.d b/examples/using-headers.d new file mode 100755 index 0000000..7c5c472 --- /dev/null +++ b/examples/using-headers.d @@ -0,0 +1,37 @@ +#!/usr/bin/env dub +/+ dub.sdl: + dependency "handy-httpd" path="../" ++/ + +/** + * This example shows how you can access a request's headers. Headers are + * stored as a constant multivalue map of strings in the context's request, + * so as you can see below, we access them via `ctx.request.headers`. + */ +module examples.using_headers; + +import handy_httpd; +import slf4d; +import std.format; + +void main(string[] args) { + ServerConfig cfg = ServerConfig.defaultValues; + if (args.length > 1) { + import std.conv; + cfg.port = args[1].to!ushort; + } + new HttpServer(&respondWithHeaders, cfg).start(); +} + +void respondWithHeaders(ref HttpRequestContext ctx) { + import handy_httpd.components.multivalue_map; + string response = "Headers:\n\n"; + foreach (name, value; ctx.request.headers) { + response ~= name ~ ": " ~ value ~ "\n"; + } + if (ctx.request.headers.contains("User-Agent")) { + string userAgent = ctx.request.headers["User-Agent"]; + response ~= "\nYour user agent is: " ~ userAgent; + } + ctx.response.writeBodyString(response); +} diff --git a/examples/websocket/server.d b/examples/websocket/server.d index 16b6f95..0486dc1 100755 --- a/examples/websocket/server.d +++ b/examples/websocket/server.d @@ -25,10 +25,14 @@ class MyWebSocketHandler : WebSocketMessageHandler { } } -void main() { - ServerConfig cfg = ServerConfig.defaultValues(); +void main(string[] args) { + ServerConfig cfg = ServerConfig.defaultValues; + if (args.length > 1) { + import std.conv; + cfg.port = args[1].to!ushort; + } cfg.workerPoolSize = 3; - cfg.workerPoolManagerIntervalMs = 5000; + cfg.enableWebSockets = true; // Important! Websockets won't work unless `enableWebSockets` is set to true! WebSocketHandler handler = new WebSocketHandler(new MyWebSocketHandler()); PathHandler pathHandler = new PathHandler() .addMapping(Method.GET, "/ws", handler) diff --git a/source/handy_httpd/components/config.d b/source/handy_httpd/components/config.d index e9827b5..b537c90 100644 --- a/source/handy_httpd/components/config.d +++ b/source/handy_httpd/components/config.d @@ -95,7 +95,7 @@ struct ServerConfig { cfg.reuseAddress = true; cfg.workerPoolSize = 25; cfg.workerPoolManagerIntervalMs = 60_000; - cfg.enableWebSockets = true; + cfg.enableWebSockets = false; return cfg; } } diff --git a/source/handy_httpd/components/form_urlencoded.d b/source/handy_httpd/components/form_urlencoded.d index 40304ba..dc9c0df 100644 --- a/source/handy_httpd/components/form_urlencoded.d +++ b/source/handy_httpd/components/form_urlencoded.d @@ -5,45 +5,12 @@ */ module handy_httpd.components.form_urlencoded; -/** - * Struct containing a single key-value pair that's obtained from parsing a - * URL's query or form-urlencoded data. - */ -struct QueryParam { - /// The name and value of this parameter. - string name, value; +import handy_httpd.components.multivalue_map; - /** - * Converts a list of query params to an associative string array. This is - * mainly meant as a holdover due to older code handling query params as - * such an associative array. Note that the params are traversed in order, - * so a parameter with the same name as a previous parameter will overwrite - * its value. - * Params: - * params = The ordered list of query params to convert. - * Returns: The associative array. - */ - static string[string] toMap(in QueryParam[] params) { - string[string] m; - foreach (QueryParam param; params) { - m[param.name] = param.value; - } - return m; - } - - /** - * Converts an associative string array into a list of query params. - * Params: - * m = The associative array of strings to convert. - * Returns: The list of query params. - */ - static QueryParam[] fromMap(in string[string] m) { - QueryParam[] params; - foreach (name, value; m) { - params ~= QueryParam(name, value); - } - return params; - } +/// An internal utility struct used when parsing. +private struct QueryParam { + string name; + string value; } /** @@ -58,7 +25,7 @@ struct QueryParam { * but added as a convenience. * Returns: A list of parsed key-value pairs. */ -QueryParam[] parseFormUrlEncoded(string queryString, bool stripWhitespace = true) { +StringMultiValueMap parseFormUrlEncoded(string queryString, bool stripWhitespace = true) { import std.array : array; import std.string : split; import std.algorithm : filter, map; @@ -67,9 +34,13 @@ QueryParam[] parseFormUrlEncoded(string queryString, bool stripWhitespace = true queryString = queryString[1..$]; } - return queryString.split("&").filter!(s => s.length > 0) - .map!(s => parseSingleQueryParam(s, stripWhitespace)) - .array; + auto params = queryString.split("&").filter!(s => s.length > 0) + .map!(s => parseSingleQueryParam(s, stripWhitespace)); + StringMultiValueMap.Builder mapBuilder; + foreach (QueryParam param; params) { + mapBuilder.add(param.name, param.value); + } + return mapBuilder.build(); } /** @@ -111,25 +82,23 @@ private QueryParam parseSingleQueryParam(string s, bool stripWhitespace) { } unittest { - void doTest(QueryParam[] expectedResult, string queryString, bool stripWhitespace = true) { + void doTest(string[][string] expectedResult, string queryString, bool stripWhitespace = true) { import std.format; auto actual = parseFormUrlEncoded(queryString, stripWhitespace); + auto expected = StringMultiValueMap.fromAssociativeArray(expectedResult); assert( - actual == expectedResult, - format!"Parsed query string resulted in %s instead of %s."(actual, expectedResult) + actual == expected, + format!"Parsed query string %s resulted in %s instead of %s."(queryString, actual, expected) ); } - doTest([QueryParam("a", "1"), QueryParam("b", "2")], "a=1&b=2"); - doTest([QueryParam("a", "1"), QueryParam("b", "2")], "?a=1&b=2"); - doTest([QueryParam("a", "1"), QueryParam("b", "2")], " a = 1 & b = 2 "); - doTest([QueryParam("a", "1"), QueryParam("b", "2")], " a = 1 & b = 2 "); - doTest([QueryParam("a", " 1")], "a=%20%201", false); - doTest([QueryParam("a", ""), QueryParam("b", ""), QueryParam("c", "hello")], "a&b&c=hello"); - doTest( - [QueryParam("a", ""), QueryParam("a", "hello"), QueryParam("a", "test"), QueryParam("b", "")], - "a&a=hello&a=test&b" - ); + doTest(["a": ["1"], "b": ["2"]], "a=1&b=2"); + doTest(["a": ["1"], "b": ["2"]], "?a=1&b=2"); + doTest(["a": ["1"], "b": ["2"]], " a = 1 & b = 2 "); + doTest(["a": ["1"], "b": ["2"]], " a = 1 & b = 2 "); + doTest(["a": [" 1"]], "a=%20%201", false); + doTest(["a": [""], "b": [""], "c": ["hello"]], "a&b&c=hello"); + doTest(["a": ["", "hello", "test"], "b": [""]], "a&a=hello&a=test&b"); // test for replacement of reserved characters - doTest([QueryParam("time", "12:34:56")], "time=12%3A34%3A56"); + doTest(["time": ["12:34:56"]], "time=12%3A34%3A56"); } diff --git a/source/handy_httpd/components/handler.d b/source/handy_httpd/components/handler.d index 379765c..8d73361 100644 --- a/source/handy_httpd/components/handler.d +++ b/source/handy_httpd/components/handler.d @@ -15,7 +15,7 @@ import slf4d; /** * A simple container for the components that are available in the context of - * handling an HttpRequest. This includes the request, response, server, worker, + * handling an HttpRequest. This includes the request, response, server, * and other associated objects. */ struct HttpRequestContext { @@ -24,21 +24,22 @@ struct HttpRequestContext { */ public HttpRequest request; + /// An alias for the context's HTTP request. + alias req = request; + /** * The response that */ public HttpResponse response; + /// An alias for the context's HTTP response. + alias resp = response; + /** * The server from which this context was created. */ public HttpServer server; - /** - * The worker thread that's handling this request. - */ - public ServerWorkerThread worker; - /** * The underlying socket to this request's client. In the vast majority of * use cases, you do not need to use this directly, as there are more diff --git a/source/handy_httpd/components/legacy_worker_pool.d b/source/handy_httpd/components/legacy_worker_pool.d new file mode 100644 index 0000000..690890e --- /dev/null +++ b/source/handy_httpd/components/legacy_worker_pool.d @@ -0,0 +1,332 @@ +/** + * This module defines the worker pool implementation for Handy-Httpd, which + * is responsible for managing the server's worker threads. + */ +module handy_httpd.components.legacy_worker_pool; + +import handy_httpd.server; +import handy_httpd.components.config; +import handy_httpd.components.worker; +import handy_httpd.components.parse_utils; +import handy_httpd.components.request_queue; +import handy_httpd.components.worker_pool : RequestWorkerPool; + +import std.conv; +import std.socket; +import std.typecons; +import core.thread; +import core.atomic; +import core.sync.rwmutex; +import core.sync.semaphore; + +import slf4d; +import httparsed; + +/** + * A managed pool of worker threads for handling requests to a server. Uses a + * separate manager thread to periodically check and adjust the pool. + */ +class LegacyWorkerPool : RequestWorkerPool { + package HttpServer server; + package ThreadGroup workerThreadGroup; + package ServerWorkerThread[] workers; + package PoolManager managerThread; + package int nextWorkerId = 1; + package ReadWriteMutex workersMutex; + package RequestQueue requestQueue; + + this(HttpServer server) { + this.server = server; + this.workerThreadGroup = new ThreadGroup(); + this.managerThread = new PoolManager(this); + this.workersMutex = new ReadWriteMutex(); + this.requestQueue = new ConcurrentBlockingRequestQueue(server.config.requestQueueSize); + } + + /** + * Starts the worker pool by spawning new worker threads and a new pool + * manager thread. + */ + void start() { + synchronized(this.workersMutex.writer) { + while (this.workers.length < this.server.config.workerPoolSize) { + ServerWorkerThread worker = new ServerWorkerThread(this.server, this.requestQueue, this.nextWorkerId++); + worker.start(); + this.workerThreadGroup.add(worker); + this.workers ~= worker; + } + } + this.managerThread = new PoolManager(this); + this.managerThread.start(); + debug_("Started the manager thread."); + } + + /** + * Stops the worker pool, by stopping all worker threads and the pool's + * manager thread. After it's stopped, the pool can be started again via + * `start()`. + */ + void stop() { + debug_("Stopping the manager thread."); + this.managerThread.stop(); + this.managerThread.notify(); + synchronized(this.workersMutex.writer) { + notifyWorkerThreads(); + try { + this.workerThreadGroup.joinAll(); + } catch (Exception e) { + error("An exception was thrown by a joined worker thread.", e); + } + debug_("All worker threads have terminated."); + foreach (worker; this.workers) { + this.workerThreadGroup.remove(worker); + } + this.workers = []; + this.nextWorkerId = 1; + } + try { + this.managerThread.join(); + } catch (Exception e) { + error("An exception was thrown when the managerThread was joined.", e); + } + debug_("The manager thread has terminated."); + } + + private void notifyWorkerThreads() { + ConcurrentBlockingRequestQueue q = cast(ConcurrentBlockingRequestQueue) this.requestQueue; + for (int i = 0; i < this.server.config.workerPoolSize; i++) { + q.notify(); + q.notify(); + } + debug_("Notified all worker threads."); + } + + void submit(Socket socket) { + this.requestQueue.enqueue(socket); + } + + /** + * Gets the size of the pool, in terms of the number of worker threads. + * Returns: The number of worker threads in this pool. + */ + uint size() { + synchronized(this.workersMutex.reader) { + return cast(uint) this.workers.length; + } + } +} + +/** + * The server worker thread is a thread that processes incoming requests from + * an `HttpServer`. + */ +class ServerWorkerThread : Thread { + /** + * The id of this worker thread. + */ + public const(int) id; + + /** + * The reusable request parser that will be called for each incoming request. + */ + private MsgParser!Msg requestParser = initParser!Msg(); + + /** + * A pre-allocated buffer for receiving data from the client. + */ + private ubyte[] receiveBuffer; + + /** + * The server that this worker belongs to. + */ + private HttpServer server; + + private RequestQueue requestQueue; + + /** + * A preconfigured SLF4D logger that uses the worker's id in its label. + */ + private Logger logger; + + /** + * A shared indicator of whether this worker is currently handling a request. + */ + private shared bool busy = false; + + /** + * Constructs this worker thread for the given server, with the given id. + * Params: + * server = The server that this thread belongs to. + * id = The thread's id. + */ + this(HttpServer server, RequestQueue requestQueue, int id) { + super(&run); + super.name("handy_httpd_worker-" ~ id.to!string); + this.id = id; + this.receiveBuffer = new ubyte[server.config.receiveBufferSize]; + this.server = server; + this.requestQueue = requestQueue; + this.logger = getLogger(super.name()); + } + + /** + * Runs the worker thread. This will run continuously until the server + * stops. The worker will do the following: + * + * 1. Wait for the next available client. + * 2. Parse the HTTP request from the client. + * 3. Handle the request using the server's handler. + */ + private void run() { + this.logger.debug_("Worker started."); + while (server.isReady) { + try { + // First try and get a socket to the client. + Socket socket = this.requestQueue.dequeue(); + if (socket !is null) { + if (!socket.isAlive) { + socket.close(); + continue; + } + atomicStore(this.busy, true); // Since we got a legit client, mark this worker as busy. + scope(exit) { + atomicStore(this.busy, false); + } + handleClient(this.server, socket, this.receiveBuffer, this.requestParser, this.logger); + } + + } catch (Exception e) { + this.logger.error("An unhandled exception occurred in this worker's `run` method.", e); + } + } + this.logger.debug_("Worker stopped normally after server was stopped."); + } + + /** + * Gets a pointer to this worker's internal pre-allocated receive buffer. + * Returns: A pointer to the worker's receive buffer. + */ + public ubyte[]* getReceiveBuffer() { + return &receiveBuffer; + } + + /** + * Gets the server that this worker was created for. + * Returns: The server. + */ + public HttpServer getServer() { + return server; + } + + /** + * Tells whether this worker is currently busy handling a request. + * Returns: True if this worker is handling a request, or false otherwise. + */ + public bool isBusy() { + return atomicLoad(this.busy); + } +} + +/** + * A thread that's dedicated to checking a worker pool at regular intervals, + * and correcting any issues it finds. + */ +package class PoolManager : Thread { + private LegacyWorkerPool pool; + private Logger logger; + private Semaphore sleepSemaphore; + private shared bool running; + + package this(LegacyWorkerPool pool) { + super(&run); + super.name("handy_httpd_worker-pool-manager"); + this.pool = pool; + this.logger = getLogger(super.name()); + this.sleepSemaphore = new Semaphore(); + } + + private void run() { + atomicStore(this.running, true); + while (atomicLoad(this.running)) { + // Sleep for a while before running checks. + bool notified = this.sleepSemaphore.wait(msecs(this.pool.server.config.workerPoolManagerIntervalMs)); + if (!notified) { + this.checkPoolHealth(); + } else { + // We were notified to quit, exit now. + this.stop(); + } + } + } + + package void notify() { + this.sleepSemaphore.notify(); + } + + package void stop() { + atomicStore(this.running, false); + } + + private void checkPoolHealth() { + this.logger.debug_("Checking worker pool health."); + uint busyCount = 0; + uint waitingCount = 0; + uint deadCount = 0; + synchronized(this.pool.workersMutex.writer) { + for (size_t idx = 0; idx < this.pool.workers.length; idx++) { + ServerWorkerThread worker = this.pool.workers[idx]; + if (!worker.isRunning()) { + // The worker died, so remove it and spawn a new one to replace it. + deadCount++; + this.pool.workerThreadGroup.remove(worker); + ServerWorkerThread newWorker = new ServerWorkerThread( + this.pool.server, this.pool.requestQueue, this.pool.nextWorkerId++ + ); + newWorker.start(); + this.pool.workerThreadGroup.add(newWorker); + this.pool.workers[idx] = newWorker; + this.logger.warnF! + "Worker %d died (probably due to an unexpected error), and was replaced by a new worker %d."( + worker.id, + newWorker.id + ); + + // Try to join the thread and report any exception that occurred. + try { + worker.join(true); + } catch (Throwable e) { + import std.format : format; + if (Exception exc = cast(Exception) e) { + logger.error( + format!"Worker %d threw an exception."(worker.id), + exc + ); + } else { + logger.errorF!"Worker %d threw a fatal error: %s"(worker.id, e.msg); + throw e; + } + } + } else { + if (worker.isBusy()) { + busyCount++; + } else { + waitingCount++; + } + } + } + } + this.logger.debugF!"Worker pool: %d busy, %d waiting, %d dead."(busyCount, waitingCount, deadCount); + if (waitingCount == 0) { + this.logger.warnF!( + "There are no worker threads available to take requests. %d are busy. " ~ + "This may be an indication of a deadlock or indefinite blocking operation." + )(busyCount); + } + // Temp check websocket manager health: + auto manager = pool.server.getWebSocketManager(); + if (manager !is null && !manager.isRunning()) { + this.logger.error("The WebSocketManager has died! Please report this issue to the author of handy-httpd."); + pool.server.reviveWebSocketManager(); + } + } +} diff --git a/source/handy_httpd/components/multipart.d b/source/handy_httpd/components/multipart.d index 0d62c21..710899b 100644 --- a/source/handy_httpd/components/multipart.d +++ b/source/handy_httpd/components/multipart.d @@ -91,7 +91,7 @@ const MAX_ELEMENTS = 1024; */ MultipartFormData readBodyAsMultipartFormData(ref HttpRequest request, bool allowInfiniteRead = false) { import std.algorithm : startsWith; - string contentType = request.getHeader("Content-Type"); + string contentType = request.headers.getFirst("Content-Type").orElse(null); if (contentType is null || !startsWith(contentType, "multipart/form-data")) { throw new MultipartFormatException("Content-Type is not multipart/form-data."); } @@ -129,7 +129,11 @@ MultipartFormData parseMultipartFormData(string content, string boundary) { while (elementCount < MAX_ELEMENTS) { // Check that we have enough data to read a boundary marker. if (content.length < nextIdx + boundary.length + 4) { - throw new MultipartFormatException("Invalid boundary: " ~ content[nextIdx .. $]); + throw new MultipartFormatException( + "Unable to read next boundary marker: " ~ + content[nextIdx .. $] ~ + ". Expected " ~ boundary + ); } string nextBoundary = content[nextIdx .. nextIdx + boundary.length + 4]; if (nextBoundary == boundaryEnd) { @@ -138,7 +142,6 @@ MultipartFormData parseMultipartFormData(string content, string boundary) { // Find the end index of this element. const ulong elementStartIdx = nextIdx + boundary.length + 4; const ulong elementEndIdx = indexOf(content, "--" ~ boundary, elementStartIdx); - // const ulong elementEndIdx = elementStartIdx + countUntil(content[elementStartIdx .. $], "--" ~ boundary); traceF!"Reading element from body at [%d, %d)"(elementStartIdx, elementEndIdx); partAppender ~= readElement(content[elementStartIdx .. elementEndIdx]); nextIdx = elementEndIdx; @@ -217,7 +220,7 @@ private ulong parseElementHeaders(ref MultipartElement element, string content) private void parseContentDisposition(ref MultipartElement element) { import std.algorithm : startsWith, endsWith; import std.string : split, strip; - import std.uri : decode; + import std.uri : decodeComponent; if ("Content-Disposition" !in element.headers) { throw new MultipartFormatException("Missing required Content-Disposition header for multipart element."); } @@ -226,11 +229,11 @@ private void parseContentDisposition(ref MultipartElement element) { foreach (string part; cdParts) { string stripped = strip(part); if (startsWith(stripped, "name=\"") && endsWith(stripped, "\"")) { - element.name = decode(stripped[6 .. $ - 1]); + element.name = decodeComponent(stripped[6 .. $ - 1]); traceF!"Element name: %s"(element.name); } else if (startsWith(stripped, "filename=\"") && endsWith(stripped, "\"")) { import std.typecons : nullable; - element.filename = nullable(decode(stripped[10 .. $ - 1])); + element.filename = nullable(decodeComponent(stripped[10 .. $ - 1])); traceF!"Element filename: %s"(element.filename); } } diff --git a/source/handy_httpd/components/multivalue_map.d b/source/handy_httpd/components/multivalue_map.d index fcb436a..16e81e3 100644 --- a/source/handy_httpd/components/multivalue_map.d +++ b/source/handy_httpd/components/multivalue_map.d @@ -11,6 +11,9 @@ import handy_httpd.components.optional; * values. */ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { + /// A convenience alias to refer to this struct's type more easily. + private alias MapType = MultiValueMap!(KeyType, ValueType, KeySort); + /// The internal structure used to store each key and set of values. static struct Entry { /// The key for this entry. @@ -28,14 +31,11 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * 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) { + private long indexOf(KeyType k) const { if (entries.length == 0) return -1; if (entries.length == 1) { return entries[0].key == k ? 0 : -1; } - if (entries.length == 2) { - return entries[0].key == k ? 0 : 1; - } size_t startIdx = 0; size_t endIdx = entries.length - 1; while (startIdx <= endIdx) { @@ -66,7 +66,7 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * Gets the number of unique keys in this map. * Returns: The number of unique keys in this map. */ - size_t length() { + size_t length() const { return entries.length; } @@ -76,11 +76,24 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * k = The key to search for. * Returns: True if at least one value exists for the given key. */ - bool contains(KeyType k) { - Optional!Entry optionalEntry = getEntry(k); + bool contains(KeyType k) const { + MapType unconstMap = cast(MapType) this; + Optional!Entry optionalEntry = unconstMap.getEntry(k); return !optionalEntry.isNull && optionalEntry.value.values.length > 0; } + /** + * Gets a list of all keys in this map. + * 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. * Params: @@ -88,8 +101,9 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * Returns: The values associated with the given key, or an empty array if * no values exist for the key. */ - ValueType[] getAll(KeyType k) { - return getEntry(k).map!(e => e.values).orElse([]); + ValueType[] getAll(KeyType k) const { + MapType unconstMap = cast(MapType) this; + return unconstMap.getEntry(k).map!(e => e.values.dup).orElse([]); } /** @@ -100,8 +114,9 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * Returns: An optional contains the value, if there is at least one value * for the given key. */ - Optional!ValueType getFirst(KeyType k) { - Optional!Entry optionalEntry = getEntry(k); + Optional!ValueType getFirst(KeyType k) const { + MapType unconstMap = cast(MapType) this; + Optional!Entry optionalEntry = unconstMap.getEntry(k); if (optionalEntry.isNull || optionalEntry.value.values.length == 0) { return Optional!ValueType.empty(); } @@ -133,7 +148,8 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { } /** - * Removes a key from the map. + * Removes a key from the map, thus removing all values associated with + * that key. * Params: * k = The key to remove. */ @@ -155,9 +171,9 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * mapped to a list of values. * Returns: The associative array. */ - ValueType[][KeyType] asAssociativeArray() { + ValueType[][KeyType] asAssociativeArray() const { ValueType[][KeyType] aa; - foreach (Entry entry; entries) { + foreach (const Entry entry; entries) { aa[entry.key] = entry.values.dup; } return aa; @@ -193,6 +209,84 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { 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 MapType 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); + auto optionalEntry = getEntry(k); + if (optionalEntry.isNull) { + entryAppender ~= Entry(k, [v]); + } else { + optionalEntry.value.values ~= v; + } + return this; + } + + /** + * Builds the multivalued map. + * Returns: The map that was created. + */ + MapType build() { + if (m.entries.length == 0) return m; + import std.algorithm.sorting : sort; + sort!((a, b) => KeySort(a.key, b.key))(m.entries); + return m; + } + + private Optional!(MapType.Entry) getEntry(KeyType k) { + foreach (MapType.Entry entry; m.entries) { + if (entry.key == k) return Optional!(MapType.Entry).of(entry); + } + return Optional!(MapType.Entry).empty(); + } + } + + // RANGE INTERFACE METHODS below here + + bool empty() const { + return length == 0; + } + + Entry front() { + return entries[0]; + } + + void popFront() { + if (length == 1) { + clear(); + } else { + entries = entries[1 .. $]; + } + } + + Entry back() { + return entries[length - 1]; + } + + void popBack() { + if (length == 1) { + clear(); + } else { + entries = entries[0..$-1]; + } + } + // OPERATOR OVERLOADS below here /** @@ -203,13 +297,34 @@ struct MultiValueMap(KeyType, ValueType, alias KeySort = (a, b) => a < b) { * key = The key to get the value of. * Returns: The first value for the given key. */ - ValueType opIndex(KeyType 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; + } } -/// The string => string mapping is a common usecase, so an alias is defined. +/** + * 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 { @@ -226,4 +341,19 @@ unittest { 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); } diff --git a/source/handy_httpd/components/parse_utils.d b/source/handy_httpd/components/parse_utils.d index 0c2531a..f77f644 100644 --- a/source/handy_httpd/components/parse_utils.d +++ b/source/handy_httpd/components/parse_utils.d @@ -10,10 +10,20 @@ import std.string; import std.algorithm; import std.uri; import std.range; +import std.socket : Socket, Address, lastSocketError; +import slf4d : Logger, getLogger; +import streams : isByteInputStream, isByteOutputStream, + inputStreamObjectFor, outputStreamObjectFor, arrayInputStreamFor, concatInputStreamFor, + bufferedInputStreamFor, StreamResult, SocketInputStream, SocketOutputStream; import httparsed; import handy_httpd.components.request : HttpRequest, methodFromName; import handy_httpd.components.form_urlencoded; +import handy_httpd.components.multivalue_map; +import handy_httpd.components.optional; +import handy_httpd.components.handler : HttpRequestContext; +import handy_httpd.components.response : HttpResponse; +import handy_httpd.server : HttpServer; /** * The header struct to use when parsing data. @@ -68,25 +78,24 @@ public struct Msg { * s = The raw HTTP request string. * Returns: A tuple containing the http request and the size of data read. */ -public Tuple!(HttpRequest, int) parseRequest(MsgParser!Msg requestParser, string s) { +public Tuple!(HttpRequest, int) parseRequest(ref MsgParser!Msg requestParser, string s) { int result = requestParser.parseRequest(s); if (result < 1) { throw new Exception("Couldn't parse header."); } - string[string] headers; + StringMultiValueMap.Builder headersBuilder; foreach (h; requestParser.headers) { - headers[h.name] = cast(string) h.value; + headersBuilder.add(cast(string) h.name, cast(string) h.value); } string rawUrl = decode(cast(string) requestParser.uri); - Tuple!(string, QueryParam[]) urlAndParams = parseUrlAndParams(rawUrl); + Tuple!(string, StringMultiValueMap) urlAndParams = parseUrlAndParams(rawUrl); string method = cast(string) requestParser.method; HttpRequest request = HttpRequest( methodFromName(method), urlAndParams[0], requestParser.minorVer, - headers, - QueryParam.toMap(urlAndParams[1]), + headersBuilder.build(), urlAndParams[1], null ); @@ -95,21 +104,18 @@ public Tuple!(HttpRequest, int) parseRequest(MsgParser!Msg requestParser, string /** * Parses a path and set of query parameters from a raw URL string. - * **Deprecated** because handy-httpd is transitioning away from AA-style - * query params. You should use `parseUrlAndParams` instead. * Params: * rawUrl = The raw url containing both path and query params. * Returns: A tuple containing the path and parsed query params. */ -public Tuple!(string, string[string]) parseUrlAndParamsAsMap(string rawUrl) { - Tuple!(string, string[string]) result; +public Tuple!(string, StringMultiValueMap) parseUrlAndParams(string rawUrl) { + Tuple!(string, StringMultiValueMap) result; auto p = rawUrl.indexOf('?'); if (p == -1) { result[0] = rawUrl; - result[1] = null; } else { result[0] = rawUrl[0..p]; - result[1] = QueryParam.toMap(parseFormUrlEncoded(rawUrl[p..$], false)); + result[1] = parseFormUrlEncoded(rawUrl[p..$], false); } // Strip away a trailing slash if there is one. This makes path matching easier. if (result[0][$ - 1] == '/') { @@ -119,23 +125,83 @@ public Tuple!(string, string[string]) parseUrlAndParamsAsMap(string rawUrl) { } /** - * Parses a path and set of query parameters from a raw URL string. + * Attempts to receive an HTTP request from the given socket. * Params: - * rawUrl = The raw url containing both path and query params. - * Returns: A tuple containing the path and parsed query params. + * server = The server that accepted the client socket. + * clientSocket = The underlying socket to the client. + * inputStream = The input stream to use. + * outputStream = The output stream to use. + * receiveBuffer = The raw buffer that is used to store data that was read. + * requestParser = The HTTP request parser. + * logger = A logger to use to write log messages. + * Returns: An optional request context. If null, then the client socket can + * be closed and no further action is required. Otherwise, it is a valid + * request context that can be handled using the server's configured handler. */ -public Tuple!(string, QueryParam[]) parseUrlAndParams(string rawUrl) { - Tuple!(string, QueryParam[]) result; - auto p = rawUrl.indexOf('?'); - if (p == -1) { - result[0] = rawUrl; - } else { - result[0] = rawUrl[0..p]; - result[1] = parseFormUrlEncoded(rawUrl[p..$], false); +public Optional!HttpRequestContext receiveRequest(InputStream, OutputStream)( + HttpServer server, + Socket clientSocket, + InputStream inputStream, + OutputStream outputStream, + ref ubyte[] receiveBuffer, + ref MsgParser!Msg requestParser, + Logger logger = getLogger() +) if (isByteInputStream!InputStream && isByteOutputStream!OutputStream) { + // First try and read as much as we can from the input stream into the buffer. + logger.trace("Reading the initial request into the receive buffer."); + StreamResult initialReadResult = inputStream.readFromStream(receiveBuffer); + if (initialReadResult.hasError) { + logger.errorF!"Encountered socket receive failure: %s, lastSocketError = %s"( + initialReadResult.error.message, + lastSocketError() + ); + return Optional!HttpRequestContext.empty(); } - // Strip away a trailing slash if there is one. This makes path matching easier. - if (result[0][$ - 1] == '/') { - result[0] = result[0][0 .. $ - 1]; + + logger.debugF!"Received %d bytes from the client."(initialReadResult.count); + if (initialReadResult.count == 0) return Optional!HttpRequestContext.empty(); // Skip if we didn't receive valid data. + + // We store an immutable copy of the data initially received, so we can + // slice it and work with it even as we keep reading and overwriting the + // receive buffer. + immutable ubyte[] initialData = receiveBuffer[0 .. initialReadResult.count].idup; + + // Prepare the request context by parsing the HttpRequest, and preparing the context. + try { + auto requestAndSize = parseRequest(requestParser, cast(string) initialData); + logger.debugF!"Parsed first %d bytes as the HTTP request."(requestAndSize[1]); + + // We got a valid request, so prepare the context. + HttpRequestContext ctx = HttpRequestContext(requestAndSize[0], HttpResponse()); + ctx.clientSocket = clientSocket; + ctx.server = server; + + ctx.request.receiveBuffer = receiveBuffer; + const int bytesReceived = initialReadResult.count; + const int bytesRead = requestAndSize[1]; + if (bytesReceived > bytesRead) { + ctx.request.inputStream = inputStreamObjectFor(concatInputStreamFor( + arrayInputStreamFor(receiveBuffer[bytesRead .. bytesReceived]), + bufferedInputStreamFor(inputStream) + )); + } else { + ctx.request.inputStream = inputStreamObjectFor(bufferedInputStreamFor(inputStream)); + } + ctx.request.remoteAddress = clientSocket.remoteAddress; + + ctx.response.outputStream = outputStreamObjectFor(outputStream); + ctx.response.headers["Connection"] = "close"; + foreach (header, value; server.config.defaultHeaders) { + ctx.response.addHeader(header, value); + } + + logger.traceF!"Preparing HttpRequestContext using input stream\n%s\nand output stream\n%s"( + ctx.request.inputStream, + ctx.response.outputStream + ); + return Optional!HttpRequestContext.of(ctx); + } catch (Exception e) { + logger.warnF!"Failed to parse HTTP request: %s"(e.msg); + return Optional!HttpRequestContext.empty(); } - return result; } diff --git a/source/handy_httpd/components/request.d b/source/handy_httpd/components/request.d index 5f2174f..78139cd 100644 --- a/source/handy_httpd/components/request.d +++ b/source/handy_httpd/components/request.d @@ -5,13 +5,18 @@ module handy_httpd.components.request; import handy_httpd.server: HttpServer; import handy_httpd.components.response : HttpResponse; -import handy_httpd.components.form_urlencoded : QueryParam, parseFormUrlEncoded; +import handy_httpd.components.form_urlencoded : parseFormUrlEncoded; +import handy_httpd.components.multivalue_map; +import handy_httpd.components.optional; import std.typecons : Nullable, nullable; import std.socket : Address; import std.exception; import slf4d; import streams; +/// For convenience, provide access to multipart functionality. +public import handy_httpd.components.multipart : readBodyAsMultipartFormData, MultipartFormData, MultipartElement; + /** * The data which the server provides to HttpRequestHandlers so that they can * formulate a response. @@ -35,33 +40,25 @@ struct HttpRequest { public const int ver = 1; /** - * An associative array containing all request headers. - */ - public const string[string] headers; - - /** - * An associative array containing all request params, if any were given. - * **Deprecated** in favor of `HttpRequest.queryParams`, because this old - * implementation doesn't follow the specification which allows for many - * values with the same name. + * A list of all request headers. */ - deprecated public const string[string] params; + public const(StringMultiValueMap) headers; /** * A list of parsed query parameters from the request's URL. */ - public QueryParam[] queryParams; + public const(StringMultiValueMap) queryParams; /** * An associative array containing any path parameters obtained from the * request url. These are only populated in cases where it is possible to - * parse path parameters, such as with a PathDelegatingHandler. + * parse path parameters, such as with a PathHandler. */ public string[string] pathParams; /** - * If this request was processed by a `PathDelegatingHandler`, then this - * will be set to the path pattern that was matched when it chose a handler + * If this request was processed by a `PathHandler`, then this will be + * set to the path pattern that was matched when it chose a handler * to handle this request. */ public string pathPattern; @@ -84,29 +81,6 @@ struct HttpRequest { */ public Address remoteAddress; - /** - * Tests if this request has a header with the given name. - * Params: - * name = The name to check for, case-sensitive. - * Returns: True if this request has a header with the given name, or false - * otherwise. - */ - public bool hasHeader(string name) const { - return (name in headers) !is null; - } - - /** - * Gets the string representation of a given header value, or null if the - * header isn't present. - * Params: - * name = The name of the header, case-sensitive. - * Returns: The header's string representation, or null if not found. - */ - public string getHeader(string name) const { - if (hasHeader(name)) return headers[name]; - return null; - } - /** * Gets a header as the specified type, or returns the default value * if the header with the given name doesn't exist or is of an invalid @@ -138,27 +112,24 @@ struct HttpRequest { */ public T getParamAs(T)(string name, T defaultValue = T.init) const { import std.conv : to, ConvException; - foreach (QueryParam param; this.queryParams) { - if (param.name == name) { + return this.queryParams.getFirst(name) + .map!((s) { try { - return param.value.to!T; + return s.to!T; } catch (ConvException e) { return defaultValue; } - } - } - return defaultValue; + }) + .orElse(defaultValue); } unittest { - QueryParam[] p = [QueryParam("a", "123"), QueryParam("b", "c"), QueryParam("c", "true")]; HttpRequest req = HttpRequest( Method.GET, "/api", 1, - string[string].init, - QueryParam.toMap(p), - p, + StringMultiValueMap.init, + StringMultiValueMap.fromAssociativeArray(["a": "123", "b": "c", "c": "true"]), string[string].init ); assert(req.getParamAs!int("a") == 123); @@ -214,27 +185,23 @@ struct HttpRequest { * Returns: The number of bytes that were read. */ public ulong readBody(S)(ref S outputStream, bool allowInfiniteRead = false) if (isByteOutputStream!S) { - const string* contentLengthStrPtr = "Content-Length" in headers; - // If we're not allowed to read infinitely, and no content-length is given, don't attempt to read. - if (!allowInfiniteRead && contentLengthStrPtr is null) { - warn("Refusing to read request body because allowInfiniteRead is " ~ - "false and no \"Content-Length\" header exists."); - return 0; - } - Nullable!ulong contentLength; - if (contentLengthStrPtr !is null) { - import std.conv : to, ConvException; - try { - contentLength = nullable((*contentLengthStrPtr).to!ulong); - } catch (ConvException e) { - warnF!"Caught ConvException: %s; while parsing Content-Length header: %s"(e.msg, *contentLengthStrPtr); - // Invalid formatting for content-length header. - // If we don't allow infinite reading, quit 0. - if (!allowInfiniteRead) { - warn("Refusing to read request body because allowInfiniteRead is false."); - return 0; + const long contentLength = headers.getFirst("Content-Length") + .map!((s) { + import std.conv : to, ConvException; + try { + return s.to!long; + } catch (ConvException e) { + warnF!"Caught ConvException: %s; while parsing Content-Length header: %s"(e.msg, s); + return -1; } - } + }) + .orElse(-1); + if (contentLength < 0 && !allowInfiniteRead) { + warn( + "Refusing to read request body because allowInfiniteRead is " ~ + "false and no valid Content-Length header was found." + ); + return 0; } return this.readBody!(S)(outputStream, contentLength); } @@ -242,6 +209,7 @@ struct HttpRequest { unittest { import std.conv; import handy_httpd.util.builders; + import slf4d.test; // Test case 1: Simply reading a string. string body1 = "Hello world!"; @@ -252,12 +220,17 @@ struct HttpRequest { assert(sOut1.toArrayRaw() == cast(ubyte[]) body1); // Test case 2: Missing Content-Length header, so we don't read anything. - string body2 = "Goodbye, world."; - HttpRequest r2 = new HttpRequestBuilder().withBody(body2).withoutHeader("Content-Length").build(); - auto sOut2 = byteArrayOutputStream(); - ulong bytesRead2 = r2.readBody(sOut2); - assert(bytesRead2 == 0); - assert(sOut2.toArrayRaw().length == 0); + // In this case, we also test that a warning message is emitted. + synchronized(loggingTestingMutex) { + shared TestingLoggingProvider loggingProvider = getTestingProvider(); + string body2 = "Goodbye, world."; + HttpRequest r2 = new HttpRequestBuilder().withBody(body2).withoutHeader("Content-Length").build(); + auto sOut2 = byteArrayOutputStream(); + ulong bytesRead2 = r2.readBody(sOut2); + assert(bytesRead2 == 0); + assert(sOut2.toArrayRaw().length == 0); + loggingProvider.assertHasMessage(Levels.WARN); + } // Test case 3: Missing Content-Length header but we allow infinite reading. string body3 = "Hello moon!"; @@ -278,7 +251,7 @@ struct HttpRequest { */ private ulong readBody(S)( ref S outputStream, - Nullable!ulong expectedLength + long expectedLength ) if (isByteOutputStream!S) { import std.algorithm : min; import std.string : toLower; @@ -287,7 +260,7 @@ struct HttpRequest { // Set up any necessary stream wrappers depending on the transfer encoding and compression. InputStream!ubyte sIn; - if (hasHeader("Transfer-Encoding") && toLower(getHeader("Transfer-Encoding")) == "chunked") { + if (headers.contains("Transfer-Encoding") && toLower(headers["Transfer-Encoding"]) == "chunked") { debug_("Request has Transfer-Encoding=chunked, using chunked input stream."); sIn = inputStreamObjectFor(chunkedEncodingInputStreamFor(this.inputStream)); } else { @@ -296,10 +269,10 @@ struct HttpRequest { ulong bytesRead = 0; - while (expectedLength.isNull || bytesRead < expectedLength.get()) { - const uint bytesToRead = expectedLength.isNull + while (expectedLength == -1 || bytesRead < expectedLength) { + const uint bytesToRead = expectedLength == -1 ? cast(uint) this.receiveBuffer.length - : min(expectedLength.get() - bytesRead, cast(uint)this.receiveBuffer.length); + : min(expectedLength - bytesRead, cast(uint)this.receiveBuffer.length); traceF!"Reading up to %d bytes from stream."(bytesToRead); StreamResult readResult = sIn.readFromStream(this.receiveBuffer[0 .. bytesToRead]); if (readResult.hasError) { @@ -361,7 +334,7 @@ struct HttpRequest { * stripWhitespace = Whether to strip whitespace from parsed keys and values. * Returns: The list of values that were parsed. */ - public QueryParam[] readBodyAsFormUrlEncoded(bool allowInfiniteRead = false, bool stripWhitespace = true) { + public StringMultiValueMap readBodyAsFormUrlEncoded(bool allowInfiniteRead = false, bool stripWhitespace = true) { return parseFormUrlEncoded(readBodyAsString(allowInfiniteRead), stripWhitespace); } diff --git a/source/handy_httpd/components/websocket/handler.d b/source/handy_httpd/components/websocket/handler.d index 8a92d78..706c5db 100644 --- a/source/handy_httpd/components/websocket/handler.d +++ b/source/handy_httpd/components/websocket/handler.d @@ -227,14 +227,14 @@ class WebSocketHandler : HttpRequestHandler { * be written in that case. */ private bool verifyRequest(ref HttpRequestContext ctx) { - string origin = ctx.request.getHeader("origin"); + string origin = ctx.request.headers.getFirst("origin").orElse(null); // TODO: Verify correct origin. if (ctx.request.method != Method.GET) { ctx.response.setStatus(HttpStatus.METHOD_NOT_ALLOWED); ctx.response.writeBodyString("Only GET requests are allowed."); return false; } - string key = ctx.request.getHeader("Sec-WebSocket-Key"); + string key = ctx.request.headers.getFirst("Sec-WebSocket-Key").orElse(null); if (key is null) { ctx.response.setStatus(HttpStatus.BAD_REQUEST); ctx.response.writeBodyString("Missing Sec-WebSocket-Key header."); @@ -256,7 +256,7 @@ class WebSocketHandler : HttpRequestHandler { * ctx = The request context to send the response to. */ private void sendSwitchingProtocolsResponse(ref HttpRequestContext ctx) { - string key = ctx.request.getHeader("Sec-WebSocket-Key"); + string key = ctx.request.headers.getFirst("Sec-WebSocket-Key").orElseThrow(); ctx.response.setStatus(HttpStatus.SWITCHING_PROTOCOLS); ctx.response.addHeader("Upgrade", "websocket"); ctx.response.addHeader("Connection", "Upgrade"); diff --git a/source/handy_httpd/components/worker.d b/source/handy_httpd/components/worker.d index a229534..4b38fdf 100644 --- a/source/handy_httpd/components/worker.d +++ b/source/handy_httpd/components/worker.d @@ -4,275 +4,85 @@ */ module handy_httpd.components.worker; -import std.socket; -import std.typecons : Nullable, nullable; -import std.conv : to; -import core.thread; -import core.atomic : atomicStore, atomicLoad; -import httparsed : MsgParser, initParser; -import slf4d; -import streams.primitives; -import streams.interfaces; -import streams.types.socket; -import streams.types.concat; -import streams.types.array; -import streams.types.buffered; +import std.socket : Socket, SocketShutdown; + +import handy_httpd.server : HttpServer; +import handy_httpd.components.parse_utils : Msg, receiveRequest; +import handy_httpd.components.handler : HttpRequestContext; +import handy_httpd.components.response : HttpStatus; -import handy_httpd.server; -import handy_httpd.components.handler; -import handy_httpd.components.request; -import handy_httpd.components.response; -import handy_httpd.components.parse_utils; +import streams : SocketInputStream, SocketOutputStream; +import httparsed : MsgParser; +import slf4d; /** - * The server worker thread is a thread that processes incoming requests from - * an `HttpServer`. + * The main logical function that's called when a new client socket is accepted + * which receives the request, handles it, and then closes the socket and frees + * any allocated resources. + * Params: + * server = The server that accepted the client. + * socket = The client's socket. + * receiveBuffer = A preallocated buffer for reading the client's request. + * requestParser = The HTTP request parser. + * logger = A logger to use for any logging messages. */ -class ServerWorkerThread : Thread { - /** - * The id of this worker thread. - */ - public const(int) id; - - /** - * The reusable request parser that will be called for each incoming request. - */ - private MsgParser!Msg requestParser = initParser!Msg(); - - /** - * A pre-allocated buffer for receiving data from the client. - */ - private ubyte[] receiveBuffer; - - /** - * The server that this worker belongs to. - */ - private HttpServer server; - - /** - * A preconfigured SLF4D logger that uses the worker's id in its label. - */ - private Logger logger; - - /** - * A shared indicator of whether this worker is currently handling a request. - */ - private shared bool busy = false; - - /** - * Constructs this worker thread for the given server, with the given id. - * Params: - * server = The server that this thread belongs to. - * id = The thread's id. - */ - this(HttpServer server, int id) { - super(&run); - super.name("handy_httpd_worker-" ~ id.to!string); - this.id = id; - this.receiveBuffer = new ubyte[server.config.receiveBufferSize]; - this.server = server; - this.logger = getLogger(super.name()); - } - - /** - * Runs the worker thread. This will run continuously until the server - * stops. The worker will do the following: - * - * 1. Wait for the next available client. - * 2. Parse the HTTP request from the client. - * 3. Handle the request using the server's handler. - */ - private void run() { - debug_("Worker started."); - while (server.isReady) { - try { - // First try and get a socket to the client. - Nullable!Socket nullableSocket = server.waitForNextClient(); - if (nullableSocket.isNull || !nullableSocket.get().isAlive()) { - if (!nullableSocket.isNull) nullableSocket.get().close(); - continue; - } - atomicStore(this.busy, true); // Since we got a legit client, mark this worker as busy. - scope(exit) { - atomicStore(this.busy, false); - } - Socket clientSocket = nullableSocket.get(); - this.logger.debugF!"Got client socket: %s"(clientSocket.remoteAddress()); - - SocketInputStream inputStream = SocketInputStream(clientSocket); - SocketOutputStream outputStream = SocketOutputStream(clientSocket); - - // Then try and parse their request and obtain a request context. - Nullable!HttpRequestContext nullableCtx = receiveRequest( - &inputStream, &outputStream, clientSocket.remoteAddress, clientSocket - ); - if (nullableCtx.isNull) { - this.logger.debug_("Skipping this request because we couldn't get a context."); - continue; - } - HttpRequestContext ctx = nullableCtx.get(); - - // Then handle the request using the server's handler. - this.logger.infoF!"Request: Method=%s, URL=\"%s\""(ctx.request.method, ctx.request.url); - try { - this.server.getHandler.handle(ctx); - if (!ctx.response.isFlushed) { - ctx.response.flushHeaders(); - } - } catch (Exception e) { - this.logger.debugF!"Encountered exception %s while handling request: %s"(e.classinfo.name, e.msg); - try { - this.server.getExceptionHandler.handle(ctx, e); - } catch (Exception e2) { - this.logger.error("Exception occurred in the server's exception handler.", e2); - } - } - // Only close the socket if we're not switching protocols. - if (ctx.response.status != HttpStatus.SWITCHING_PROTOCOLS) { - clientSocket.shutdown(SocketShutdown.BOTH); - clientSocket.close(); - // Destroy the request context's allocated objects. - destroy!(false)(ctx.request.inputStream); - destroy!(false)(ctx.response.outputStream); - destroy!(false)(ctx.metadata); - } else { - this.logger.debug_("Keeping socket alive due to SWITCHING_PROTOCOLS status."); - } - - this.logger.infoF!"Response: Status=%d %s"(ctx.response.status.code, ctx.response.status.text); - - // Reset the request parser so we're ready for the next request. - requestParser.msg.reset(); - } catch (Exception e) { - logger.error("An unhandled exception occurred in this worker's `run` method.", e); - } - } - debug_("Worker stopped normally after server was stopped."); +public void handleClient( + HttpServer server, + Socket socket, + ref ubyte[] receiveBuffer, + ref MsgParser!Msg requestParser, + Logger logger = getLogger() +) { + logger.debugF!"Got client socket: %s"(socket.remoteAddress()); + // Create the input and output streams for the request here, since their + // lifetime continues until the request is handled. + SocketInputStream inputStream = SocketInputStream(socket); + SocketOutputStream outputStream = SocketOutputStream(socket); + // Try to parse and build a request context by reading from the socket. + auto optionalCtx = receiveRequest( + server, socket, + &inputStream, &outputStream, + receiveBuffer, + requestParser, + logger + ); + if (optionalCtx.isNull) { + logger.debug_("Skipping this request because we couldn't get a context."); + socket.shutdown(SocketShutdown.BOTH); + socket.close(); + return; } - /** - * Attempts to receive an HTTP request from the given socket. - * Params: - * inputStream = The input stream to read the request from. - * outputStream = The output stream to write response content to. - * remoteAddress = The client's address. - * clientSocket = The underlying socket to the client. - * Returns: A nullable request context, which if present, can be used to - * further handle the request. If null, no further action should be taken - * beyond closing the socket. - */ - private Nullable!HttpRequestContext receiveRequest(StreamIn, StreamOut)( - StreamIn inputStream, - StreamOut outputStream, - Address remoteAddress, - Socket clientSocket - ) if (isByteInputStream!StreamIn && isByteOutputStream!StreamOut) { - this.logger.trace("Reading the initial request into the receive buffer."); - StreamResult initialReadResult = inputStream.readFromStream(this.receiveBuffer); - if (initialReadResult.hasError) { - this.logger.errorF!"Encountered socket receive failure: %s, lastSocketError = %s"( - initialReadResult.error.message, - lastSocketError() - ); - return Nullable!HttpRequestContext.init; + // We successfully got a request, so use the server's handler to handle it. + HttpRequestContext ctx = optionalCtx.value; + logger.infoF!"Request: Method=%s, URL=\"%s\""(ctx.request.method, ctx.request.url); + try { + server.getHandler.handle(ctx); + if (!ctx.response.isFlushed) { + ctx.response.flushHeaders(); } - this.logger.debugF!"Received %d bytes from the client."(initialReadResult.count); - if (initialReadResult.count == 0) { - return Nullable!HttpRequestContext.init; // Skip if we didn't receive valid data. - } - immutable ubyte[] data = receiveBuffer[0 .. initialReadResult.count].idup; - - // Prepare the request context by parsing the HttpRequest, and preparing the context. + } catch (Exception e) { + logger.debugF!"Encountered exception %s while handling request: %s"(e.classinfo.name, e.msg); try { - auto requestAndSize = handy_httpd.components.parse_utils.parseRequest(requestParser, cast(string) data); - this.logger.debugF!"Parsed first %d bytes as the HTTP request."(requestAndSize[1]); - return nullable(prepareRequestContext( - requestAndSize[0], - requestAndSize[1], - initialReadResult.count, - inputStream, - outputStream, - remoteAddress, - clientSocket - )); - } catch (Exception e) { - this.logger.warnF!"Failed to parse HTTP request: %s"(e.msg); - return Nullable!HttpRequestContext.init; + server.getExceptionHandler.handle(ctx, e); + } catch (Exception e2) { + logger.error("Exception occurred in the server's exception handler.", e2); } } - - /** - * Helper method to build the request context from the basic components - * obtained from parsing a request. - * Params: - * parsedRequest = The parsed request. - * bytesRead = The number of bytes read during request parsing. - * bytesReceived = The number of bytes initially received. - * inputStream = The stream to read the request from. - * outputStream = The stream to write response content to. - * remoteAddress = The client's address. - * clientSocket = The underlying socket to the client. - * Returns: A request context that is ready for handling. - */ - private HttpRequestContext prepareRequestContext(StreamIn, StreamOut)( - HttpRequest parsedRequest, - size_t bytesRead, - size_t bytesReceived, - StreamIn inputStream, - StreamOut outputStream, - Address remoteAddress, - Socket clientSocket - ) if (isByteInputStream!StreamIn && isByteOutputStream!StreamOut) { - HttpRequestContext ctx = HttpRequestContext( - parsedRequest, - HttpResponse(), - this.server, - this - ); - ctx.request.receiveBuffer = this.receiveBuffer; - if (bytesReceived > bytesRead) { - ctx.request.inputStream = inputStreamObjectFor(concatInputStreamFor( - arrayInputStreamFor(this.receiveBuffer[bytesRead .. bytesReceived]), - bufferedInputStreamFor(inputStream) - )); - } else { - ctx.request.inputStream = inputStreamObjectFor(bufferedInputStreamFor(inputStream)); - } - ctx.request.remoteAddress = remoteAddress; - ctx.clientSocket = clientSocket; - ctx.response.outputStream = outputStreamObjectFor(outputStream); - ctx.response.headers["Connection"] = "close"; // Always set Connection: close. Handler must set keep-alive manually if they wish to do so. - this.logger.traceF!"Preparing HttpRequestContext using input stream\n%s\nand output stream\n%s"( - ctx.request.inputStream, - ctx.response.outputStream - ); - foreach (headerName, headerValue; this.server.config.defaultHeaders) { - ctx.response.addHeader(headerName, headerValue); - } - return ctx; - } - - /** - * Gets a pointer to this worker's internal pre-allocated receive buffer. - * Returns: A pointer to the worker's receive buffer. - */ - public ubyte[]* getReceiveBuffer() { - return &receiveBuffer; + // Only close the socket if we're not switching protocols. + if (ctx.response.status != HttpStatus.SWITCHING_PROTOCOLS) { + socket.shutdown(SocketShutdown.BOTH); + socket.close(); + // Destroy the request context's allocated objects. + destroy!(false)(ctx.request.inputStream); + destroy!(false)(ctx.response.outputStream); + destroy!(false)(ctx.metadata); + } else { + logger.debug_("Keeping socket alive due to SWITCHING_PROTOCOLS status."); } - /** - * Gets the server that this worker was created for. - * Returns: The server. - */ - public HttpServer getServer() { - return server; - } + logger.infoF!"Response: Status=%d %s"(ctx.response.status.code, ctx.response.status.text); - /** - * Tells whether this worker is currently busy handling a request. - * Returns: True if this worker is handling a request, or false otherwise. - */ - public bool isBusy() { - return atomicLoad(this.busy); - } -} \ No newline at end of file + // Reset the request parser so we're ready for the next request. + requestParser.msg.reset(); +} diff --git a/source/handy_httpd/components/worker_pool.d b/source/handy_httpd/components/worker_pool.d index 3ec3e83..f2f4e91 100644 --- a/source/handy_httpd/components/worker_pool.d +++ b/source/handy_httpd/components/worker_pool.d @@ -1,194 +1,112 @@ /** - * This module defines the worker pool implementation for Handy-Httpd, which - * is responsible for managing the server's worker threads. + * This module defines the request worker pool interface, as well as some + * basic implementations of it. */ module handy_httpd.components.worker_pool; -import handy_httpd.server; -import handy_httpd.components.config; -import handy_httpd.components.worker; -import core.thread; -import core.atomic; -import core.sync.rwmutex; -import core.sync.semaphore; -import slf4d; +import std.socket : Socket; /** - * A managed pool of worker threads for handling requests to a server. Uses a - * separate manager thread to periodically check and adjust the pool. + * A pool to which connecting client sockets can be submitted so that their + * requests may be handled. */ -class WorkerPool { - package HttpServer server; - package ThreadGroup workerThreadGroup; - package ServerWorkerThread[] workers; - package PoolManager managerThread; - package int nextWorkerId = 1; - package ReadWriteMutex workersMutex; - - this(HttpServer server) { - this.server = server; - this.workerThreadGroup = new ThreadGroup(); - this.managerThread = new PoolManager(this); - this.workersMutex = new ReadWriteMutex(); - } - +interface RequestWorkerPool { /** - * Starts the worker pool by spawning new worker threads and a new pool - * manager thread. + * Starts the pool, so that it will be able to process requests. */ - void start() { - synchronized(this.workersMutex.writer) { - while (this.workers.length < this.server.config.workerPoolSize) { - ServerWorkerThread worker = new ServerWorkerThread(this.server, this.nextWorkerId++); - worker.start(); - this.workerThreadGroup.add(worker); - this.workers ~= worker; - } - } - this.managerThread = new PoolManager(this); - this.managerThread.start(); - debug_("Started the manager thread."); - } + void start(); /** - * Stops the worker pool, by stopping all worker threads and the pool's - * manager thread. After it's stopped, the pool can be started again via - * `start()`. + * Submits a client socket to this pool for processing. + * Params: + * socket = The client socket. */ - void stop() { - debug_("Stopping the manager thread."); - this.managerThread.stop(); - this.managerThread.notify(); - synchronized(this.workersMutex.writer) { - this.server.notifyWorkerThreads(); - try { - this.workerThreadGroup.joinAll(); - } catch (Exception e) { - error("An exception was thrown by a joined worker thread.", e); - } - debug_("All worker threads have terminated."); - foreach (worker; this.workers) { - this.workerThreadGroup.remove(worker); - } - this.workers = []; - this.nextWorkerId = 1; - } - try { - this.managerThread.join(); - } catch (Exception e) { - error("An exception was thrown when the managerThread was joined.", e); - } - debug_("The manager thread has terminated."); - } + void submit(Socket socket); /** - * Gets the size of the pool, in terms of the number of worker threads. - * Returns: The number of worker threads in this pool. + * Stops the pool, so that no more requests may be processed. */ - uint size() { - synchronized(this.workersMutex.reader) { - return cast(uint) this.workers.length; - } - } + void stop(); } /** - * A thread that's dedicated to checking a worker pool at regular intervals, - * and correcting any issues it finds. + * A basic worker pool implementation that uses Phobos' std.parallelism and + * its TaskPool to asynchronously process requests. Due to the temporary nature + * of Phobos' tasks, a new receive buffer must be allocated for each request. */ -package class PoolManager : Thread { - private WorkerPool pool; - private Logger logger; - private Semaphore sleepSemaphore; - private shared bool running; +class TaskPoolWorkerPool : RequestWorkerPool { + import std.parallelism; + import handy_httpd.components.worker; + import handy_httpd.server : HttpServer; + import handy_httpd.components.parse_utils : Msg; + import httparsed : initParser, MsgParser; + + private TaskPool taskPool; + private HttpServer server; + private size_t workerCount; + + /** + * Constructs this worker pool for the given server. + * Params: + * server = The server to construct this worker pool for. + * workerCount = The number of workers to use. + */ + this(HttpServer server, size_t workerCount) { + this.server = server; + this.workerCount = workerCount; + } - package this(WorkerPool pool) { - super(&run); - super.name("handy_httpd_worker-pool-manager"); - this.pool = pool; - this.logger = getLogger(super.name()); - this.sleepSemaphore = new Semaphore(); + void start() { + this.taskPool = new TaskPool(this.workerCount); } - private void run() { - atomicStore(this.running, true); - while (atomicLoad(this.running)) { - // Sleep for a while before running checks. - bool notified = this.sleepSemaphore.wait(msecs(this.pool.server.config.workerPoolManagerIntervalMs)); - if (!notified) { - this.checkPoolHealth(); - } else { - // We were notified to quit, exit now. - this.stop(); - } - } + void submit(Socket socket) { + ubyte[] receiveBuffer = new ubyte[server.config.receiveBufferSize]; + MsgParser!Msg requestParser = initParser!Msg(); + auto t = task!handleClient( + server, + socket, + receiveBuffer, + requestParser + ); + this.taskPool.put(t); } - package void notify() { - this.sleepSemaphore.notify(); + void stop() { + this.taskPool.finish(true); + } +} + +/** + * A worker pool implementation that isn't even a pool, but simply executes + * all request processing as soon as a socket is submitted, on the calling + * thread. It uses a single buffer and parser for all requests. + */ +class BlockingWorkerPool : RequestWorkerPool { + import handy_httpd.server : HttpServer; + import handy_httpd.components.worker; + import handy_httpd.components.parse_utils : Msg; + import httparsed : MsgParser; + import core.thread; + + private HttpServer server; + private ubyte[] receiveBuffer; + private MsgParser!Msg requestParser; + + this(HttpServer server) { + this.server = server; + this.receiveBuffer = new ubyte[server.config.receiveBufferSize]; } - package void stop() { - atomicStore(this.running, false); + void start() { + // Nothing to start. } - private void checkPoolHealth() { - uint busyCount = 0; - uint waitingCount = 0; - uint deadCount = 0; - synchronized(this.pool.workersMutex.writer) { - for (size_t idx = 0; idx < this.pool.workers.length; idx++) { - ServerWorkerThread worker = this.pool.workers[idx]; - if (!worker.isRunning()) { - // The worker died, so remove it and spawn a new one to replace it. - deadCount++; - this.pool.workerThreadGroup.remove(worker); - ServerWorkerThread newWorker = new ServerWorkerThread(this.pool.server, this.pool.nextWorkerId++); - newWorker.start(); - this.pool.workerThreadGroup.add(newWorker); - this.pool.workers[idx] = newWorker; - this.logger.warnF! - "Worker %d died (probably due to an unexpected error), and was replaced by a new worker %d."( - worker.id, - newWorker.id - ); + void submit(Socket socket) { + handleClient(this.server, socket, this.receiveBuffer, this.requestParser); + } - // Try to join the thread and report any exception that occurred. - try { - worker.join(true); - } catch (Throwable e) { - import std.format : format; - if (Exception exc = cast(Exception) e) { - logger.error( - format!"Worker %d threw an exception."(worker.id), - exc - ); - } else { - logger.errorF!"Worker %d threw a fatal error: %s"(worker.id, e.msg); - throw e; - } - } - } else { - if (worker.isBusy()) { - busyCount++; - } else { - waitingCount++; - } - } - } - } - this.logger.debugF!"Worker pool: %d busy, %d waiting, %d dead."(busyCount, waitingCount, deadCount); - if (waitingCount == 0) { - this.logger.warnF!( - "There are no worker threads available to take requests. %d are busy. " ~ - "This may be an indication of a deadlock or indefinite blocking operation." - )(busyCount); - } - // Temp check websocket manager health: - auto manager = pool.server.getWebSocketManager(); - if (manager !is null && !manager.isRunning()) { - this.logger.error("The WebSocketManager has died! Please report this issue to the author of handy-httpd."); - pool.server.reviveWebSocketManager(); - } + void stop() { + // Nothing to stop. } } diff --git a/source/handy_httpd/components/worker_pool2/package.d b/source/handy_httpd/components/worker_pool2/package.d deleted file mode 100644 index 0041452..0000000 --- a/source/handy_httpd/components/worker_pool2/package.d +++ /dev/null @@ -1,6 +0,0 @@ -module handy_httpd.components.worker_pool2; - -interface RequestWorkerPool { - void start(); - void stop(); -} diff --git a/source/handy_httpd/handlers/package.d b/source/handy_httpd/handlers/package.d index 5146ea2..303c47f 100644 --- a/source/handy_httpd/handlers/package.d +++ b/source/handy_httpd/handlers/package.d @@ -5,5 +5,6 @@ module handy_httpd.handlers; public import handy_httpd.handlers.file_resolving_handler; -public import handy_httpd.handlers.path_delegating_handler; public import handy_httpd.handlers.filtered_handler; +public import handy_httpd.handlers.path_handler; +public import handy_httpd.handlers.profiling_handler; diff --git a/source/handy_httpd/handlers/path_delegating_handler.d b/source/handy_httpd/handlers/path_delegating_handler.d deleted file mode 100644 index 1027b6d..0000000 --- a/source/handy_httpd/handlers/path_delegating_handler.d +++ /dev/null @@ -1,562 +0,0 @@ -/** - * Notice! This module is deprecated in favor of the [PathHandler]. - * - * This module defines a [PathDelegatingHandler] that delegates the handling of - * requests to other handlers, using some matching logic. - * - * The [PathDelegatingHandler] works by adding "mappings" to it, which map some - * properties of HTTP requests, like the URL, method, path parameters, etc, to - * a particular [HttpRequestHandler]. - * - * When looking for a matching handler, the PathDelegatingHandler will check - * its list of handlers in the order that they were added, and the first - * mapping that matches the request will take it. - * If a PathDelegatingHandler receives a request for which no mapping matches, - * then a configurable `notFoundHandler` is called to handle the request. By - * default, it just applies a basic [HttpStatus.NOT_FOUND] response. - * - * ## The Handler Mapping - * - * The [HandlerMapping] is a simple struct that maps certain properties of an - * [HttpRequest] to a particular [HttpRequestHandler]. The PathDelegatingHandler - * keeps a list of these mappings at runtime, and uses them to determine which - * handler to delegate to. - * Each mapping can apply to a certain set of HTTP methods (GET, POST, PATCH, - * etc.), as well as a set of URL path patterns. - * - * Most often, you'll use the [HandlerMappingBuilder] or one of the `addMapping` - * methods provided by [PathDelegatingHandler]. - * - * When specifying the set of HTTP methods that a mapping applies to, you may - * specify a list of methods, a single one, or none at all (which implicitly - * matches requests with any HTTP method). - * - * ### Path Patterns - * - * The matching rules for path patterns are inspired by those of Spring - * Framework's [AntPathMatcher](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/AntPathMatcher.html) - * In short, URLs are matched according to the following rules: - * $(LIST - * * `?` matches a single character. - * * `*` matches zero or more characters. - * * `**` matches zero or more segments in a URL. - * * `{value:[a-z]+}` matches a path variable named "value" that conforms to the regular expression `[a-z]+`. - * ) - */ -module handy_httpd.handlers.path_delegating_handler; - -import handy_httpd.components.handler; -import handy_httpd.components.request; -import handy_httpd.components.response; -import slf4d; - -import std.regex; -import std.typecons; - -/** - * An exception that's thrown if an invalid handler mapping is defined by a - * user of the PathDelegatingHandler class. - */ -class HandlerMappingException : Exception { - import std.exception : basicExceptionCtors; - mixin basicExceptionCtors; -} - -/** - * A request handler that delegates handling of requests to other handlers, - * based on a configured Ant-style path pattern. - */ -class PathDelegatingHandler : HttpRequestHandler { - private HandlerMapping[] handlerMappings; - - /** - * A handler to delegate to when no matching handler is found for a - * request. Defaults to a simple 404 response. - */ - private HttpRequestHandler notFoundHandler; - - /** - * Constructs this handler with an empty list of mappings, and a default - * `notFoundHandler` that just sets a 404 status. - */ - this() { - this.handlerMappings = []; - this.notFoundHandler = toHandler((ref ctx) { ctx.response.status = HttpStatus.NOT_FOUND; }); - } - - /** - * Adds a new pre-built handler mapping to this handler. - * Params: - * mapping = The mapping to add. - * Returns: A reference to this handler. - */ - public PathDelegatingHandler addMapping(HandlerMapping mapping) { - this.handlerMappings ~= mapping; - return this; - } - - /** - * Obtains a new builder with a fluent interface for constructing a new - * handler mapping. - * Returns: The builder. - */ - public HandlerMappingBuilder addMapping() { - return new HandlerMappingBuilder(this); - } - - /// Overload of `addMapping`. - public PathDelegatingHandler addMapping(Method[] methods, string[] pathPatterns, HttpRequestHandler handler) { - return this.addMapping() - .forMethods(methods) - .forPaths(pathPatterns) - .withHandler(handler) - .add(); - } - - /// Overload of `addMapping` which accepts a function handler. - public PathDelegatingHandler addMapping(Method[] methods, string[] pathPatterns, HttpRequestHandlerFunction fn) { - return this.addMapping(methods, pathPatterns, toHandler(fn)); - } - - /// Overload of `addMapping` which accepts a single HTTP method. - public PathDelegatingHandler addMapping(Method method, string[] pathPatterns, HttpRequestHandler handler) { - Method[1] arr = [method]; - return this.addMapping(arr, pathPatterns, handler); - } - - /// Overload of `addMapping` which accepts a single HTTP method and function handler. - public PathDelegatingHandler addMapping(Method method, string[] pathPatterns, HttpRequestHandlerFunction fn) { - return this.addMapping(method, pathPatterns, toHandler(fn)); - } - - /// Overload of `addMapping` which accepts a single path pattern. - public PathDelegatingHandler addMapping(Method[] methods, string pathPattern, HttpRequestHandler handler) { - string[1] arr = [pathPattern]; - return this.addMapping(methods, arr, handler); - } - - /// Overload of `addMapping` which accepts a single HTTP method and single path pattern. - public PathDelegatingHandler addMapping(Method method, string pathPattern, HttpRequestHandler handler) { - Method[1] m = [method]; - string[1] p = [pathPattern]; - return this.addMapping(m, p, handler); - } - - /// Overload of `addMapping` which accepts a single HTTP method and single path pattern, and a function handler. - public PathDelegatingHandler addMapping(Method method, string pathPattern, HttpRequestHandlerFunction fn) { - return this.addMapping(method, pathPattern, toHandler(fn)); - } - - /// Overload of `addMapping` which accepts a single path pattern and applies to all HTTP methods. - public PathDelegatingHandler addMapping(string pathPattern, HttpRequestHandler handler) { - return this.addMapping().forPath(pathPattern).withHandler(handler).add(); - } - - /// Overload of `addMapping` which accepts a single path pattern and function handler, and applies to all HTTP methods. - public PathDelegatingHandler addMapping(string pathPattern, HttpRequestHandlerFunction fn) { - return this.addMapping().forPath(pathPattern).withHandler(fn).add(); - } - - /** - * Sets a handler to use when no matching handler was found for a request's - * path. - * Params: - * handler = The handler to use. It should not be null. - * Returns: This handler, for method chaining. - */ - public PathDelegatingHandler setNotFoundHandler(HttpRequestHandler handler) { - if (handler is null) throw new Exception("Cannot set notFoundHandler to null."); - this.notFoundHandler = handler; - return this; - } - - unittest { - import std.exception; - auto handler = new PathDelegatingHandler(); - assertThrown!Exception(handler.setNotFoundHandler(null)); - auto notFoundHandler = toHandler((ref ctx) { - ctx.response.status = HttpStatus.NOT_FOUND; - }); - assertNotThrown!Exception(handler.setNotFoundHandler(notFoundHandler)); - } - - /** - * Handles an incoming request by delegating to the first registered - * handler that matches the request's url path. If no handler is found, - * a 404 NOT FOUND response is sent by default. - * Params: - * ctx = The request context. - */ - void handle(ref HttpRequestContext ctx) { - foreach (mapping; handlerMappings) { - if ((mapping.methodsMask & ctx.request.method) > 0) { - traceF!"Checking if patterns %s match url %s"(mapping.pathPatterns, ctx.request.url); - for (size_t patternIdx = 0; patternIdx < mapping.pathPatterns.length; patternIdx++) { - string pathPattern = mapping.pathPatterns[patternIdx]; - immutable Regex!char compiledPattern = mapping.compiledPatterns[patternIdx]; - immutable string[] paramNames = mapping.pathParamNames[patternIdx]; - Captures!string captures = matchFirst(ctx.request.url, compiledPattern); - if (!captures.empty) { - debugF!"Found matching handler for %s %s (pattern: \"%s\")"( - ctx.request.method, - ctx.request.url, - pathPattern - ); - traceF!"Captures: %s"(captures); - foreach (string paramName; paramNames) { - ctx.request.pathParams[paramName] = captures[paramName]; - } - ctx.request.pathPattern = pathPattern; - mapping.handler.handle(ctx); - return; - } - } - } - } - debugF!"No matching handler found for %s %s"(ctx.request.method, ctx.request.url); - notFoundHandler.handle(ctx); - } - - unittest { - import handy_httpd.server; - import handy_httpd.components.responses; - import handy_httpd.util.builders; - - auto handler = new PathDelegatingHandler() - .addMapping(Method.GET, "/home", (ref ctx) {ctx.response.okResponse();}) - .addMapping(Method.GET, "/users", (ref ctx) {ctx.response.okResponse();}) - .addMapping(Method.GET, "/users/{id}", (ref ctx) {ctx.response.okResponse();}) - .addMapping(Method.GET, "/api/*", (ref ctx) {ctx.response.okResponse();}); - - /* - To test the handle() method, we create a pair of dummy sockets and a dummy - server to satisfy dependencies, then create some fake request contexts and - see how the handler changes them. - */ - HttpRequestContext generateHandledCtx(string method, string url) { - auto ctx = buildCtxForRequest(methodFromName(method), url); - handler.handle(ctx); - return ctx; - } - - assert(generateHandledCtx("GET", "/home").response.status == HttpStatus.OK); - assert(generateHandledCtx("GET", "/home-not-exists").response.status == HttpStatus.NOT_FOUND); - assert(generateHandledCtx("GET", "/users").response.status == HttpStatus.OK); - assert(generateHandledCtx("GET", "/users/34").response.status == HttpStatus.OK); - assert(generateHandledCtx("GET", "/users/34").request.getPathParamAs!int("id") == 34); - assert(generateHandledCtx("GET", "/api/test").response.status == HttpStatus.OK); - assert(generateHandledCtx("GET", "/api/test/bleh").response.status == HttpStatus.NOT_FOUND); - assert(generateHandledCtx("GET", "/api").response.status == HttpStatus.NOT_FOUND); - assert(generateHandledCtx("GET", "/").response.status == HttpStatus.NOT_FOUND); - } -} - -/** - * Represents a mapping of a specific request handler to a subset of URLs - * and/or request methods. - */ -struct HandlerMapping { - /** - * The original pattern(s) used to match against URLs. - */ - immutable string[] pathPatterns; - - /** - * A bitmask that contains a 1 for each HTTP method this mapping applies to. - */ - immutable ushort methodsMask; - - /** - * The handler to apply to requests whose URL and method match this - * mapping's path pattern and methods list. - */ - HttpRequestHandler handler; - - /** - * The compiled regular expression(s) used to match URLs. - */ - immutable Regex!(char)[] compiledPatterns; - - /** - * A cached list of all expected path parameter names, which are used to - * get path params from a regex match. There is one list of path parameter - * names for each pathPattern. - */ - immutable string[][] pathParamNames; -} - -/** - * A builder for constructing handler mappings using a fluent method interface - * and support for inlining within the context of a PathDelegatingHandler's - * methods. - */ -class HandlerMappingBuilder { - private PathDelegatingHandler pdh; - private Method[] methods; - private string[] pathPatterns; - private HttpRequestHandler handler; - - this() {} - - this(PathDelegatingHandler pdh) { - this.pdh = pdh; - } - - HandlerMappingBuilder forMethods(Method[] methods) { - this.methods = methods; - return this; - } - - HandlerMappingBuilder forMethod(Method method) { - this.methods ~= method; - return this; - } - - HandlerMappingBuilder forPaths(string[] pathPatterns) { - this.pathPatterns = pathPatterns; - return this; - } - - HandlerMappingBuilder forPath(string pathPattern) { - this.pathPatterns ~= pathPattern; - return this; - } - - HandlerMappingBuilder withHandler(HttpRequestHandler handler) { - this.handler = handler; - return this; - } - - HandlerMappingBuilder withHandler(HttpRequestHandlerFunction fn) { - this.handler = toHandler(fn); - return this; - } - - /** - * Builds a handler mapping from this builder's configured information. - * Returns: The handler mapping. - */ - HandlerMapping build() { - import std.string : format, join; - if (handler is null) { - throw new HandlerMappingException("Cannot create a HandlerMapping with a null handler."); - } - if (pathPatterns is null || pathPatterns.length == 0) { - pathPatterns = ["/**"]; - } - immutable ushort methodsMask = (methods !is null && methods.length > 0) - ? methodMaskFromMethods(methods) - : methodMaskFromAll(); - Regex!(char)[] regexes = new Regex!(char)[pathPatterns.length]; - string[][] pathParamNames = new string[][pathPatterns.length]; - foreach (size_t i, string pathPattern; pathPatterns) { - auto t = compilePathPattern(pathPattern); - regexes[i] = t.regex; - pathParamNames[i] = t.pathParamNames; - } - import std.algorithm : map; - import std.array : array; - return HandlerMapping( - pathPatterns.idup, - methodsMask, - handler, - cast(immutable Regex!(char)[]) regexes, - pathParamNames.map!(a => a.idup).array.idup // Dirty hack to turn string[][] into immutable. - ); - } - - /** - * Adds the handler mapping produced by this builder to the - * PathDelegatingHandler that this builder was initialized with. - * Returns: The PathDelegatingHandler that the mapping was added to. - */ - PathDelegatingHandler add() { - if (pdh is null) { - throw new HandlerMappingException( - "Cannot add HandlerMapping to a PathDelegatingHandler when none was used to initialize the builder." - ); - } - return pdh.addMapping(this.build()); - } -} - -/** - * Compiles a "path pattern" into a Regex that can be used at runtime for - * matching against request URLs. For the path pattern specification, please - * see this module's documentation. - * Params: - * pattern = The path pattern to compile. - * Returns: A tuple containing a regex to match the given pattern, and a list - * of path parameter names that were parsed from the pattern. - */ -public Tuple!(Regex!char, "regex", string[], "pathParamNames") compilePathPattern(string pattern) { - import std.algorithm : canFind; - import std.format : format; - import std.array : replaceFirst; - - immutable string originalPattern = pattern; - - // First pass, where we tag all wildcards for replacement on a second pass. - pattern = replaceAll(pattern, ctRegex!(`/\*\*`), "--<>--"); - pattern = replaceAll(pattern, ctRegex!(`/\*`), "--<>--"); - pattern = replaceAll(pattern, ctRegex!(`\?`), "--<>--"); - - // Replace path parameter expressions with regex expressions for them, with named capture groups. - auto pathParamResults = parsePathParamExpressions(pattern, originalPattern); - pattern = pathParamResults.pattern; - string[] pathParamNames = pathParamResults.pathParamNames; - - // Finally, second pass where wildcard placeholders are swapped for their regex pattern. - pattern = replaceAll(pattern, ctRegex!(`--<>--`), `(?:/[^/]+)*/?`); - pattern = replaceAll(pattern, ctRegex!(`--<>--`), `/[^/]+`); - pattern = replaceAll(pattern, ctRegex!(`--<>--`), `[^/]`); - - // Add anchors to start and end of string. - pattern = "^" ~ pattern ~ "$"; - debugF!"Compiled path pattern \"%s\" to regex \"%s\""(originalPattern, pattern); - - return tuple!("regex", "pathParamNames")(regex(pattern), pathParamNames); -} - -/** - * Helper function that parses and replaces path parameter expressions, like - * "/users/{userId:uint}", with a regex that captures the path parameter, with - * support for matching the parameter's type. - * Params: - * pattern = The full URL pattern string. - * originalPattern = The original pattern that was provided when compiling. - * Returns: The URL pattern string, with path parameters replaced with an - * appropriate regex, and the list of path parameter names. - */ -private Tuple!(string, "pattern", string[], "pathParamNames") parsePathParamExpressions( - string pattern, - string originalPattern -) { - import std.algorithm : canFind; - import std.string : format; - import std.array : replaceFirst; - - auto pathParamRegex = ctRegex!(`\{(?P[a-zA-Z][a-zA-Z0-9_-]*)(?::(?P[^}]+))?\}`); - auto pathParamMatches = matchAll(pattern, pathParamRegex); - string[] pathParamNames; - foreach (capture; pathParamMatches) { - string paramName = capture["name"]; - // Check that the name of this path parameter is unique. - if (canFind(pathParamNames, paramName)) { - throw new HandlerMappingException( - format!"Duplicate path parameter with name \"%s\" in pattern \"%s\"."(paramName, originalPattern) - ); - } - pathParamNames ~= paramName; - - string paramType = capture["type"]; - string paramPattern = "[^/]+"; // The default parameter pattern if no type or pattern is defined. - if (paramType !is null) { - immutable string[string] DEFAULT_PATH_PARAMETER_TYPE_PATTERNS = [ - "int": `-?[0-9]+`, - "uint": `[0-9]+`, - "string": `\w+`, - "uuid": `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}` - ]; - bool foundMatch = false; - foreach (typeName, typePattern; DEFAULT_PATH_PARAMETER_TYPE_PATTERNS) { - if (paramType == typeName) { - paramPattern = typePattern; - foundMatch = true; - break; - } - } - if (!foundMatch) { - paramPattern = paramType; // No pre-defined type was found, use what the person wrote as a pattern itself. - } - } - pattern = replaceFirst(pattern, capture.hit, format!"(?P<%s>%s)"(paramName, paramPattern)); - } - return tuple!("pattern", "pathParamNames")(pattern, pathParamNames); -} - -unittest { - import std.format : format; - - void assertMatches(string pattern, string[] examples) { - if (examples.length == 0) assert(false, "No examples."); - auto r = compilePathPattern(pattern).regex; - foreach (example; examples) { - auto captures = matchFirst(example, r); - assert( - !captures.empty, - format!"Example \"%s\" doesn't match pattern: \"%s\". Regex: %s"(example, pattern, r) - ); - } - } - - void assertNotMatches(string pattern, string[] examples) { - if (examples.length == 0) assert(false, "No examples."); - auto r = compilePathPattern(pattern).regex; - foreach (example; examples) { - auto captures = matchFirst(example, r); - assert( - captures.empty, - format!"Example \"%s\" matches pattern: \"%s\". Regex: %s"(example, pattern, r) - ); - } - } - - // Test multi-segment wildcard patterns. - assertMatches("/users/**", [ - "/users/andrew", - "/users/", - "/users", - "/users/123", - "/users/123/john" - ]); - assertNotMatches("/users/**", [ - "/user", - "/user-not" - ]); - - // Test single-segment wildcard patterns. - assertMatches("/users/*", [ - "/users/andrew", - "/users/john", - "/users/wilson", - "/users/123" - ]); - assertNotMatches("/users/*", [ - "/users", - "/users/", - "/users/andrew/john", - "/user" - ]); - - // Test single-char wildcard patterns. - assertMatches("/data?", ["/datax", "/datay", "/dataa"]); - assertNotMatches("/data?", ["/data/x", "/dataxy", "/data"]); - - // Test complex combined patterns. - assertMatches("/users/{userId}/*/settings/**", [ - "/users/123/username/settings/abc/123", - "/users/john/pw/settings/test", - "/users/123/username/settings" - ]); - assertNotMatches("/users/{userId}/*/settings/**", [ - "/users", - "/users/settings/123", - "/users/andrew" - ]); - - // Test path param patterns. - assertMatches("/users/{userId:int}", ["/users/123", "/users/001", "/users/-42"]); - assertNotMatches("/users/{userId:int}", ["/users/andrew", "/users", "/users/-", "/users/123a3"]); - assertMatches("/{path:string}", ["/andrew", "/john"]); - assertMatches("/digit/{d:[0-9]}", ["/digit/0", "/digit/8"]); - assertNotMatches("/digit/{d:[0-9]}", ["/digit", "/digit/a", "/digit/123"]); - - // Test path param named capture groups. - auto t = compilePathPattern("/users/{userId:int}/settings/{settingName:string}"); - assert(t.pathParamNames == ["userId", "settingName"]); - auto m = matchFirst("/users/123/settings/brightness", t.regex); - assert(!m.empty); - assert(m["userId"] == "123"); - assert(m["settingName"] == "brightness"); -} diff --git a/source/handy_httpd/server.d b/source/handy_httpd/server.d index 68d14e8..aa19c13 100644 --- a/source/handy_httpd/server.d +++ b/source/handy_httpd/server.d @@ -18,6 +18,7 @@ import handy_httpd.components.config; import handy_httpd.components.worker; import handy_httpd.components.request_queue; import handy_httpd.components.worker_pool; +import handy_httpd.components.legacy_worker_pool; import handy_httpd.components.websocket; import slf4d; @@ -28,53 +29,71 @@ import slf4d; * client. */ class HttpServer { - /** + /** * The server's configuration values. */ public const ServerConfig config; - /** + /** * The address to which this server is bound. */ private Address address; - /** + /** * The handler that all requests will be delegated to. */ private HttpRequestHandler handler; - /** + /** * An exception handler to use when an exception occurs. */ private ServerExceptionHandler exceptionHandler; - /** + /** * Internal flag that indicates when we're ready to accept connections. */ private shared bool ready = false; - /** + /** * The server socket that accepts connections. */ private Socket serverSocket = null; /** - * The queue to which incoming client sockets are sent, and that workers - * pull from. + * The worker pool to which accepted client sockets are submitted for + * processing. */ - private RequestQueue requestQueue; - - /** - * The managed thread pool containing workers to handle requests. - */ - private WorkerPool workerPool; + private RequestWorkerPool requestWorkerPool; /** * A manager thread for handling all websocket connections. */ private WebSocketManager websocketManager; - /** + /** + * Constructs a new server using the supplied handler to handle all + * incoming requests, as well as a supplied request worker pool. + * Params: + * handler = The handler to use. + * requestWorkerPool = The worker pool to use. + * config = The server configuration. + */ + this( + HttpRequestHandler handler, + RequestWorkerPool requestWorkerPool, + ServerConfig config + ) { + this.config = config; + this.address = parseAddress(config.hostname, config.port); + this.handler = handler; + this.exceptionHandler = new BasicServerExceptionHandler(); + this.requestWorkerPool = requestWorkerPool; + if (config.enableWebSockets) { + this.websocketManager = new WebSocketManager(); + } + } + + /** * Constructs a new server using the supplied handler to handle all * incoming requests. * Params: @@ -88,9 +107,8 @@ class HttpServer { this.config = config; this.address = parseAddress(config.hostname, config.port); this.handler = handler; - this.requestQueue = new ConcurrentBlockingRequestQueue(config.requestQueueSize); this.exceptionHandler = new BasicServerExceptionHandler(); - this.workerPool = new WorkerPool(this); + this.requestWorkerPool = new LegacyWorkerPool(this); if (config.enableWebSockets) { this.websocketManager = new WebSocketManager(); } @@ -121,14 +139,12 @@ class HttpServer { this.prepareToStart(); atomicStore(this.ready, true); trace("Set ready flag to true."); - // The worker pool must be started after setting ready to true, since - // the workers will stop once ready is false. - this.workerPool.start(); + this.requestWorkerPool.start(); info("Now accepting connections."); while (this.serverSocket.isAlive()) { try { Socket clientSocket = this.serverSocket.accept(); - this.requestQueue.enqueue(clientSocket); + this.requestWorkerPool.submit(clientSocket); } catch (SocketAcceptException acceptException) { if (this.serverSocket.isAlive()) { warnF!"Socket accept failed: %s"(acceptException.msg); @@ -170,7 +186,7 @@ class HttpServer { * clean up any additonal resources or threads spawned by the server. */ private void cleanUpAfterStop() { - this.workerPool.stop(); + this.requestWorkerPool.stop(); if (this.websocketManager !is null) { this.websocketManager.stop(); try { @@ -216,36 +232,6 @@ class HttpServer { return atomicLoad(this.ready); } - /** - * Blocks the calling thread until we're notified by a semaphore, and tries - * to obtain the next socket to a client for which we should process a - * request. - * - * This method is intended to be called by worker threads. - * - * Returns: A nullable socket, which, if not null, contains a socket that's - * ready for request processing. - */ - public Nullable!Socket waitForNextClient() { - Nullable!Socket result; - Socket s = this.requestQueue.dequeue(); - if (s !is null) result = s; - return result; - } - - /** - * Notifies all worker threads waiting on incoming requests. This is called - * by the worker pool when it shuts down, to cause all workers to quit waiting. - */ - public void notifyWorkerThreads() { - ConcurrentBlockingRequestQueue q = cast(ConcurrentBlockingRequestQueue) this.requestQueue; - for (int i = 0; i < this.config.workerPoolSize; i++) { - q.notify(); - q.notify(); - } - debug_("Notified all worker threads."); - } - /** * Gets the configured handler for requests. * Returns: The handler. diff --git a/source/handy_httpd/util/builders.d b/source/handy_httpd/util/builders.d index e40ddde..51be04e 100644 --- a/source/handy_httpd/util/builders.d +++ b/source/handy_httpd/util/builders.d @@ -11,6 +11,7 @@ import handy_httpd.components.request; import handy_httpd.components.response; import handy_httpd.components.parse_utils; import handy_httpd.components.form_urlencoded; +import handy_httpd.components.multivalue_map; import streams; import std.socket : Address; @@ -64,7 +65,6 @@ class HttpRequestContextBuilder { private HttpRequestBuilder requestBuilder; private HttpResponseBuilder responseBuilder; private HttpServer server; - private ServerWorkerThread worker; this() { this.requestBuilder = new HttpRequestBuilder(this); @@ -124,17 +124,6 @@ class HttpRequestContextBuilder { return this; } - /** - * Configures the worker thread for this context. - * Params: - * worker = The worker thread to use. - * Returns: The context builder, for method chaining. - */ - public HttpRequestContextBuilder withWorker(ServerWorkerThread worker) { - this.worker = worker; - return this; - } - /** * Builds the request context. * Returns: The HttpRequestContext. @@ -143,8 +132,7 @@ class HttpRequestContextBuilder { return HttpRequestContext( this.requestBuilder.build(), this.responseBuilder.build(), - this.server, - this.worker + this.server ); } } @@ -158,8 +146,8 @@ class HttpRequestBuilder { private Method method = Method.GET; private string url = "/"; - private string[string] headers; - private QueryParam[] params; + private StringMultiValueMap headers; + private StringMultiValueMap params; private string[string] pathParams; private string pathPattern = null; private InputStream!ubyte inputStream = null; @@ -191,13 +179,13 @@ class HttpRequestBuilder { } HttpRequestBuilder withHeader(string name, string value) { - this.headers[name] = value; + this.headers.add(name, value); return this; } HttpRequestBuilder withHeader(V)(string name, V value) { import std.conv : to; - this.headers[name] = value.to!string; + this.headers.add(name, value.to!string); return this; } @@ -212,17 +200,17 @@ class HttpRequestBuilder { } HttpRequestBuilder withParams(string[string] params) { - this.params = QueryParam.fromMap(params); + this.params = StringMultiValueMap.fromAssociativeArray(params); return this; } - HttpRequestBuilder withParams(QueryParam[] params) { + HttpRequestBuilder withParams(StringMultiValueMap params) { this.params = params; return this; } HttpRequestBuilder withParam(string name, string value) { - this.params ~= QueryParam(name, value); + this.params.add(name, value); return this; } @@ -270,7 +258,6 @@ class HttpRequestBuilder { this.url, 1, this.headers, - QueryParam.toMap(this.params), this.params, this.pathParams, this.pathPattern,