diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 19ce754..1656fd1 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -17,3 +17,16 @@ jobs: compiler: ldc-latest - name: Build and Test run: dub -q test + + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup DLang + uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + + - name: http1-test + working-directory: integration-tests + run: dub run --single http1-test.d diff --git a/integration-tests/.gitignore b/integration-tests/.gitignore new file mode 100644 index 0000000..7827364 --- /dev/null +++ b/integration-tests/.gitignore @@ -0,0 +1 @@ +http1-test \ No newline at end of file diff --git a/integration-tests/http1-test.d b/integration-tests/http1-test.d new file mode 100755 index 0000000..fce0e14 --- /dev/null +++ b/integration-tests/http1-test.d @@ -0,0 +1,48 @@ +/+ dub.sdl: + dependency "handy-http-transport" path="../" + dependency "requests" version="~>2.1" + dependency "streams" path="/home/andrew/Code/github-andrewlalis/streams" + dependency "slf4d" path="/home/andrew/Code/github-andrewlalis/slf4d" ++/ + +/** + * This tests the basic HTTP functionality of the Http1Transport implementation + * by starting a server, sending a request, and checking the response. + */ +module integration_tests.http1_test; + +import handy_http_primitives; +import handy_http_transport; +import slf4d; +import slf4d.default_provider; +import requests; + +import core.thread; + +int main() { + auto loggingProvider = new shared DefaultProvider(true, Levels.INFO); + configureLoggingProvider(loggingProvider); + + HttpTransport transport = new Http1Transport(HttpRequestHandler.of((ref ServerHttpRequest request, ref ServerHttpResponse response) { + response.headers.add("Content-Type", "text/plain"); + response.headers.add("Content-Length", "13"); + response.outputStream.writeToStream(cast(ubyte[]) "Hello, world!"); + })); + Thread thread = transport.startInNewThread(); + scope(exit) { + transport.stop(); + thread.join(); + } + info("Started server in another thread."); + Thread.sleep(msecs(100)); + + auto content = getContent("http://localhost:8080"); + ubyte[] data = content.data; + if (data.length != 13 || (cast(string) data) != "Hello, world!") { + error("Received unexpected content: " ~ cast(string) data); + return 1; + } + + info("Test completed successfully."); + return 0; +} diff --git a/source/handy_http_transport/helpers.d b/source/handy_http_transport/helpers.d index 6ea3f2e..5203a33 100644 --- a/source/handy_http_transport/helpers.d +++ b/source/handy_http_transport/helpers.d @@ -11,16 +11,18 @@ import streams; * Returns: The string that was read, or a stream error. */ Either!(string, "value", StreamError, "error") consumeUntil(S)( - ref S inputStream, + S inputStream, string target ) if (isByteInputStream!S) { + import slf4d; + // info("consumeUntil called."); ubyte[1024] buffer; size_t idx; while (true) { auto result = inputStream.readFromStream(buffer[idx .. idx + 1]); if (result.hasError) return Either!(string, "value", StreamError, "error")(result.error); if (result.count != 1) return Either!(string, "value", StreamError, "error")( - StreamError("Failed to read a single element", 1) + StreamError("Failed to read a single element", 0) ); idx++; if (idx >= target.length && buffer[idx - target.length .. idx] == target) { diff --git a/source/handy_http_transport/http1/transport.d b/source/handy_http_transport/http1/transport.d index d811683..8dbadfd 100644 --- a/source/handy_http_transport/http1/transport.d +++ b/source/handy_http_transport/http1/transport.d @@ -23,6 +23,12 @@ class Http1Transport : HttpTransport { private const ushort port; private bool running = false; + /** + * Constructs a new Http1Transport server instance. + * Params: + * requestHandler = The request handler to use for all requests. + * port = The port to bind to. + */ this(HttpRequestHandler requestHandler, ushort port = 8080) { assert(requestHandler !is null); this.serverSocket = new TcpSocket(); @@ -30,18 +36,33 @@ class Http1Transport : HttpTransport { this.port = port; } + /** + * Starts the server. Internally, this starts the Photon event loop and + * accepts incoming connections in a separate fiber. Then, clients are + * handled in their own separate fiber (think "coroutine"). + */ void start() { - debugF!"Starting HTTP1Transport server on port %d."(port); + debugF!"Starting server on port %d."(port); startloop(); go(() => runServer()); runFibers(); } + /** + * Stops the server. This will mark the server as no longer running, so + * no more connections will be accepted. + */ void stop() { - debugF!"Stopping HTTP1Transport server on port %d."(port); + debugF!"Stopping server on port %d."(port); this.running = false; - this.serverSocket.shutdown(SocketShutdown.BOTH); - this.serverSocket.close(); + // Send a dummy request to cause the server's blocking accept() call to end. + try { + Socket dummySocket = new TcpSocket(this.serverSocket.localAddress()); + dummySocket.shutdown(SocketShutdown.BOTH); + dummySocket.close(); + } catch (SocketOSException e) { + warn("Failed to send empty request to stop server.", e); + } } private void runServer() { @@ -58,6 +79,8 @@ class Http1Transport : HttpTransport { warn("Failed to accept socket connection.", e); } } + this.serverSocket.shutdown(SocketShutdown.BOTH); + this.serverSocket.close(); } } @@ -76,10 +99,14 @@ void handleClient(Socket clientSocket, HttpRequestHandler requestHandler) { // Get remote address from the socket. import handy_http_primitives.address; ClientAddress addr = getAddress(clientSocket); - traceF!"Handling client request from %s."(addr.toString()); + debugF!"Handling client request from %s."(addr.toString()); auto result = readHttpRequest(&bufferedInput, addr); + debug_("Finished reading HTTP request from client."); if (result.hasError) { - warnF!"Failed to read HTTP request: %s"(result.error.message); + if (result.error.code != -1) { + // Only warn if we didn't read an empty request. + warnF!"Failed to read HTTP request: %s"(result.error.message); + } inputStream.closeStream(); return; } @@ -182,9 +209,17 @@ alias HttpRequestParseResult = Either!(ServerHttpRequest, "request", StreamError */ HttpRequestParseResult readHttpRequest(S)(S inputStream, in ClientAddress addr) if (isByteInputStream!S) { auto methodStr = consumeUntil(inputStream, " "); - if (methodStr.hasError) return HttpRequestParseResult(methodStr.error); + if (methodStr.hasError) { + if (methodStr.error.code == 0) { + // Set a custom code to indicate an empty request. + return HttpRequestParseResult(StreamError(methodStr.error.message, -1)); + } + return HttpRequestParseResult(methodStr.error); + } + auto urlStr = consumeUntil(inputStream, " "); if (urlStr.hasError) return HttpRequestParseResult(urlStr.error); + auto versionStr = consumeUntil(inputStream, "\r\n"); if (versionStr.hasError) return HttpRequestParseResult(versionStr.error); diff --git a/source/handy_http_transport/interfaces.d b/source/handy_http_transport/interfaces.d index 826b349..462fe16 100644 --- a/source/handy_http_transport/interfaces.d +++ b/source/handy_http_transport/interfaces.d @@ -4,3 +4,21 @@ interface HttpTransport { void start(); void stop(); } + +import core.thread; + +/** + * Starts a new thread to run an HTTP transport implementation in, separate + * from the calling thread. This is useful for running a server in the + * background, like for integration tests. + * Params: + * transport = The transport implementation to start. + * Returns: The thread that was started. + */ +Thread startInNewThread(HttpTransport transport) { + Thread t = new Thread(() { + transport.start(); + }); + t.start(); + return t; +}