diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8edbc4d4..0e1f2022 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,10 +107,10 @@ jobs: # Run stubs - name: Run HTTP stub (Windows) - run: cmd /c "start /b node test\http\stub.js" && Start-Sleep -Seconds 2 + run: cmd /c "start /b node test\http\stub.js" && Start-Sleep -Seconds 10 if: runner.os == 'windows' - name: Run HTTP stub (*nix) - run: (node test/http/stub.js &) && sleep 2 + run: (node test/http/stub.js &) && sleep 10 if: runner.os != 'windows' # Not every CTest version supports the --test-dir option. If such option diff --git a/src/http/include/sourcemeta/hydra/http_request.h b/src/http/include/sourcemeta/hydra/http_request.h index b6b1e5ce..7ff9de8f 100644 --- a/src/http/include/sourcemeta/hydra/http_request.h +++ b/src/http/include/sourcemeta/hydra/http_request.h @@ -19,6 +19,7 @@ namespace sourcemeta::hydra::http { +// TODO: Support passing a request body class SOURCEMETA_HYDRA_HTTP_EXPORT Request { public: Request(std::string url); diff --git a/src/http/include/sourcemeta/hydra/http_response.h b/src/http/include/sourcemeta/hydra/http_response.h index 4415af64..3b622642 100644 --- a/src/http/include/sourcemeta/hydra/http_response.h +++ b/src/http/include/sourcemeta/hydra/http_response.h @@ -23,6 +23,7 @@ class SOURCEMETA_HYDRA_HTTP_EXPORT Response { auto status() const noexcept -> Status; auto header(const std::string &key) const -> std::optional; + auto empty() noexcept -> bool; auto body() -> std::istringstream &; private: diff --git a/src/http/response.cc b/src/http/response.cc index 555d1715..081ad8c2 100644 --- a/src/http/response.cc +++ b/src/http/response.cc @@ -1,6 +1,7 @@ #include #include +#include // assert #include // std::map #include // std::optional, std::nullopt #include // std::ostringstream, std::istringstream @@ -26,6 +27,11 @@ auto Response::header(const std::string &key) const return this->headers_.at(key); } -auto Response::body() -> std::istringstream & { return this->stream_; } +auto Response::empty() noexcept -> bool { return this->stream_.peek() == -1; } + +auto Response::body() -> std::istringstream & { + assert(!this->empty()); + return this->stream_; +} } // namespace sourcemeta::hydra::http diff --git a/src/http/stream_curl.cc b/src/http/stream_curl.cc index 2cbe795e..92ea0631 100644 --- a/src/http/stream_curl.cc +++ b/src/http/stream_curl.cc @@ -5,11 +5,14 @@ #include +#include // std::transform #include // assert +#include // std::tolower #include // std::from_chars #include // std::size_t #include // std::uint64_t #include // std::future +#include // std::back_inserter #include // std::make_unique #include // std::optional #include // std::stringstream @@ -107,9 +110,15 @@ auto callback_on_header(const void *const data, const std::size_t size, if (request->internal->on_header) { try { assert(request->internal->status.has_value()); + + std::string_view key{line.substr(key_start, key_end - key_start)}; + std::string key_lowercase; + std::transform( + key.cbegin(), key.cend(), std::back_inserter(key_lowercase), + [](unsigned char character) { return std::tolower(character); }); + request->internal->on_header( - request->internal->status.value(), - line.substr(key_start, key_end - key_start), + request->internal->status.value(), key_lowercase, line.substr(value_start, value_end - value_start)); } catch (...) { return 0; diff --git a/test/http/CMakeLists.txt b/test/http/CMakeLists.txt index ba809664..707a5e21 100644 --- a/test/http/CMakeLists.txt +++ b/test/http/CMakeLists.txt @@ -1,4 +1,5 @@ -# Tests +# TODO: Add HTTP/2 tests + add_executable(sourcemeta_hydra_http_unit request_1_1_test.cc) sourcemeta_hydra_add_compile_options(sourcemeta_hydra_http_unit) diff --git a/test/http/request_1_1_test.cc b/test/http/request_1_1_test.cc index 5029bdff..9f24df22 100644 --- a/test/http/request_1_1_test.cc +++ b/test/http/request_1_1_test.cc @@ -1,11 +1,232 @@ +#include +#include +#include +#include + #include #include -// TODO: Add more tests +static auto body(sourcemeta::hydra::http::Response &response) -> std::string { + std::ostringstream result; + std::copy( + std::istreambuf_iterator(response.body()), + std::istreambuf_iterator(), + std::ostreambuf_iterator(result)); + return result.str(); +} + +TEST(HTTP_Request_1_1, GET_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); +} + +TEST(HTTP_Request_1_1, HEAD_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::HEAD); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response.empty()); +} + +TEST(HTTP_Request_1_1, POST_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::POST); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED POST /"); +} + +TEST(HTTP_Request_1_1, PUT_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::PUT); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED PUT /"); +} + +TEST(HTTP_Request_1_1, DELETE_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::DELETE); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED DELETE /"); +} + +TEST(HTTP_Request_1_1, OPTIONS_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::OPTIONS); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED OPTIONS /"); +} + +TEST(HTTP_Request_1_1, TRACE_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::TRACE); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED TRACE /"); +} + +TEST(HTTP_Request_1_1, PATCH_root) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::PATCH); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED PATCH /"); +} + +TEST(HTTP_Request_1_1, GET_root_foo_bar) { + sourcemeta::hydra::http::Request request{std::string{BASE_URL} + "/foo/bar"}; + request.method(sourcemeta::hydra::http::Method::GET); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /foo/bar"); +} + +TEST(HTTP_Request_1_1, GET_root_custom_code_string) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.header("X-Code", "400"); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::BAD_REQUEST); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); +} + +TEST(HTTP_Request_1_1, GET_root_custom_code_integer) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.header("X-Code", 400); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::BAD_REQUEST); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); +} + +TEST(HTTP_Request_1_1, GET_root_response_content_type_no_capture) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_FALSE(response.header("content-type").has_value()); +} + +TEST(HTTP_Request_1_1, GET_root_response_content_type_capture) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture("content-type"); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_EQ(response.header("content-type").value(), "text/plain"); +} + +TEST(HTTP_Request_1_1, GET_root_missing_header_with_capture) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture("x-foo-bar"); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_FALSE(response.header("x-foo-bar").has_value()); +} + +TEST(HTTP_Request_1_1, GET_root_multiple_captures_match) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture("content-type"); + request.capture("content-length"); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-type").value(), "text/plain"); + EXPECT_EQ(response.header("content-length").value(), "14"); +} + +TEST(HTTP_Request_1_1, GET_root_multiple_captures_partial_match) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture("content-type"); + request.capture("x-foo-bar"); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_FALSE(response.header("x-foo-bar").has_value()); + EXPECT_EQ(response.header("content-type").value(), "text/plain"); +} + +TEST(HTTP_Request_1_1, GET_root_multiple_captures_match_initializer_list) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture({"content-type", "content-length"}); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-type").value(), "text/plain"); + EXPECT_EQ(response.header("content-length").value(), "14"); +} + +TEST(HTTP_Request_1_1, + GET_root_multiple_captures_partial_match_initializer_list) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture({"content-type", "x-foo-bar"}); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_FALSE(response.header("x-foo-bar").has_value()); + EXPECT_EQ(response.header("content-type").value(), "text/plain"); +} + +TEST(HTTP_Request_1_1, GET_root_request_header_echo) { + sourcemeta::hydra::http::Request request{BASE_URL}; + request.method(sourcemeta::hydra::http::Method::GET); + request.header("x-foo-bar", "foo"); + request.capture("x-x-foo-bar"); + sourcemeta::hydra::http::Response response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("x-x-foo-bar").has_value()); + EXPECT_EQ(response.header("x-x-foo-bar").value(), "foo"); +} -TEST(HTTP_Request_1_1, XXX) { +TEST(HTTP_Request_1_1, GET_root_request_header_mixed_case_echo) { sourcemeta::hydra::http::Request request{BASE_URL}; request.method(sourcemeta::hydra::http::Method::GET); + request.header("X-Foo-Bar", "foo"); + request.capture("x-x-foo-bar"); sourcemeta::hydra::http::Response response{request.send().get()}; EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_FALSE(response.empty()); + EXPECT_EQ(body(response), "RECEIVED GET /"); + EXPECT_TRUE(response.header("x-x-foo-bar").has_value()); + EXPECT_EQ(response.header("x-x-foo-bar").value(), "foo"); } diff --git a/test/http/stub.js b/test/http/stub.js index 8e134ecb..98709d6a 100644 --- a/test/http/stub.js +++ b/test/http/stub.js @@ -1,20 +1,17 @@ const http = require('http'); +const PORT = 9999; -const server = http.createServer((req, res) => { - // Set the response header to indicate JSON content - res.setHeader('Content-Type', 'application/json'); - - // Define a simple JSON response - const jsonResponse = { - message: 'Hello, JSON World!', - timestamp: new Date().toISOString(), - }; +http.createServer((request, response) => { + for (const [key, value] of Object.entries(request.headers)) { + response.setHeader(`X-${key}`, value); + } - // Send the JSON response - res.end(JSON.stringify(jsonResponse)); -}); + if (request.headers['x-code']) { + response.statusCode = parseInt(request.headers['x-code'], 10); + } -const PORT = 9999; -server.listen(PORT, () => { + response.setHeader('Content-Type', 'text/plain'); + response.end(`RECEIVED ${request.method} ${request.url}`); +}).listen(PORT, () => { console.log(`Server is listening on port ${PORT}`); });