diff --git a/src/httpserver/CMakeLists.txt b/src/httpserver/CMakeLists.txt index cc166a1d..e993ceda 100644 --- a/src/httpserver/CMakeLists.txt +++ b/src/httpserver/CMakeLists.txt @@ -1,7 +1,7 @@ noa_library(NAMESPACE sourcemeta PROJECT hydra NAME httpserver FOLDER "Hydra/HTTP Server" PRIVATE_HEADERS request.h response.h logger.h - SOURCES uwebsockets.h httpserver.cc request.cc response.cc logger.cc) + SOURCES uwebsockets.h httpserver.cc request.cc response.cc logger.cc static.cc) if(HYDRA_INSTALL) noa_library_install(NAMESPACE sourcemeta PROJECT hydra NAME httpserver) diff --git a/src/httpserver/include/sourcemeta/hydra/httpserver.h b/src/httpserver/include/sourcemeta/hydra/httpserver.h index a5a99765..15e5d4e2 100644 --- a/src/httpserver/include/sourcemeta/hydra/httpserver.h +++ b/src/httpserver/include/sourcemeta/hydra/httpserver.h @@ -23,6 +23,7 @@ #include // std::uint32_t #include // std::exception_ptr +#include // std::filesystem::path #include // std::function #include // std::string #include // std::tuple @@ -198,6 +199,37 @@ class SOURCEMETA_HYDRA_HTTPSERVER_EXPORT Server { ServerLogger logger{"global"}; }; +/// @ingroup httpserver +/// Serve a static file. This function assumes that the file exists and that is +/// not a directory. For example: +/// +/// ```cpp +/// #include +/// +/// static auto +/// on_static(const sourcemeta::hydra::http::ServerLogger &, +/// const sourcemeta::hydra::http::ServerRequest &request, +/// sourcemeta::hydra::http::ServerResponse &response) -> void { +/// const std::filesystem::path file_path{"path/to/static" + request.path()}; +/// if (!std::filesystem::exists(file_path)) { +/// response.status(sourcemeta::hydra::http::Status::NOT_FOUND); +/// response.end(); +/// return; +/// } +/// +/// sourcemeta::hydra::http::serve_file(file_path, request, response); +/// } +/// +/// int main() { +/// sourcemeta::hydra::http::Server server; +/// server.route(sourcemeta::hydra::http::Method::GET, "/*", on_static); +/// return server.run(3000); +/// } +/// ``` +auto SOURCEMETA_HYDRA_HTTPSERVER_EXPORT +serve_file(const std::filesystem::path &file_path, const ServerRequest &request, + ServerResponse &response) -> void; + } // namespace sourcemeta::hydra::http #endif diff --git a/src/httpserver/static.cc b/src/httpserver/static.cc new file mode 100644 index 00000000..21f57f7f --- /dev/null +++ b/src/httpserver/static.cc @@ -0,0 +1,63 @@ +#include +#include + +#include // assert +#include // std::filesystem +#include // std::ifstream +#include // std::ostringstream + +namespace sourcemeta::hydra::http { + +auto serve_file(const std::filesystem::path &file_path, + const ServerRequest &request, + ServerResponse &response) -> void { + assert(request.method() == sourcemeta::hydra::http::Method::GET || + request.method() == sourcemeta::hydra::http::Method::HEAD); + + // Its the responsibility of the caller to ensure the file path + // exists, otherwise we cannot know how the application prefers + // to react to such case. + assert(std::filesystem::exists(file_path)); + assert(std::filesystem::is_regular_file(file_path)); + + const auto last_write_time{std::filesystem::last_write_time(file_path)}; + const auto last_modified{ + std::chrono::time_point_cast( + last_write_time - std::filesystem::file_time_type::clock::now() + + std::chrono::system_clock::now())}; + + if (!request.header_if_modified_since(last_modified)) { + response.status(sourcemeta::hydra::http::Status::NOT_MODIFIED); + response.end(); + return; + } + + std::ifstream stream{std::filesystem::canonical(file_path)}; + stream.exceptions(std::ifstream::failbit | std::ifstream::badbit); + assert(!stream.fail()); + assert(stream.is_open()); + + std::ostringstream contents; + contents << stream.rdbuf(); + std::ostringstream etag; + sourcemeta::hydra::md5(contents.str(), etag); + + if (!request.header_if_none_match(etag.str())) { + response.status(sourcemeta::hydra::http::Status::NOT_MODIFIED); + response.end(); + return; + } + + response.status(sourcemeta::hydra::http::Status::OK); + response.header("Content-Type", sourcemeta::hydra::mime_type(file_path)); + response.header_etag(etag.str()); + response.header_last_modified(last_modified); + + if (request.method() == sourcemeta::hydra::http::Method::HEAD) { + response.head(contents.str()); + } else { + response.end(contents.str()); + } +} + +} // namespace sourcemeta::hydra::http diff --git a/test/e2e/httpserver/CMakeLists.txt b/test/e2e/httpserver/CMakeLists.txt index 49011714..25a0bf84 100644 --- a/test/e2e/httpserver/CMakeLists.txt +++ b/test/e2e/httpserver/CMakeLists.txt @@ -5,6 +5,8 @@ target_link_libraries(sourcemeta_hydra_httpserver_e2e_stub PRIVATE sourcemeta::jsontoolkit::json) target_link_libraries(sourcemeta_hydra_httpserver_e2e_stub PRIVATE sourcemeta::jsontoolkit::jsonschema) +target_compile_definitions(sourcemeta_hydra_httpserver_e2e_stub PRIVATE + STATIC_PATH="${CMAKE_CURRENT_SOURCE_DIR}/static") noa_add_default_options(PRIVATE sourcemeta_hydra_httpserver_e2e_stub) add_executable(sourcemeta_hydra_httpserver_e2e diff --git a/test/e2e/httpserver/httpserver_test.cc b/test/e2e/httpserver/httpserver_test.cc index 1ea030a1..ad7a1059 100644 --- a/test/e2e/httpserver/httpserver_test.cc +++ b/test/e2e/httpserver/httpserver_test.cc @@ -917,3 +917,198 @@ TEST(e2e_HTTP_Server, text_head) { EXPECT_EQ(response.header("content-length").value(), "27"); EXPECT_TRUE(response.empty()); } + +TEST(e2e_HTTP_Server, static_document_get) { + sourcemeta::hydra::http::ClientRequest request{ + std::string{HTTPSERVER_BASE_URL()} + "/static/document.json"}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture(); + sourcemeta::hydra::http::ClientResponse response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-length").value(), "37"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_EQ(response.header("content-type").value(), "application/json"); + EXPECT_TRUE(response.header("last-modified").has_value()); + EXPECT_TRUE(response.header("etag").has_value()); + EXPECT_EQ(response.header("etag").value(), + "\"cef8a5c5d7fcfb9cf601bf3a146a47e7\""); + + std::ostringstream result; + std::copy( + std::istreambuf_iterator(response.body()), + std::istreambuf_iterator(), + std::ostreambuf_iterator(result)); + + EXPECT_EQ(result.str(), "{ \"foo\": \"bar\" }\n"); +} + +TEST(e2e_HTTP_Server, static_document_head) { + sourcemeta::hydra::http::ClientRequest request{ + std::string{HTTPSERVER_BASE_URL()} + "/static/document.json"}; + request.method(sourcemeta::hydra::http::Method::HEAD); + request.capture(); + sourcemeta::hydra::http::ClientResponse response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-length").value(), "37"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_EQ(response.header("content-type").value(), "application/json"); + EXPECT_TRUE(response.header("last-modified").has_value()); + EXPECT_TRUE(response.header("etag").has_value()); + EXPECT_EQ(response.header("etag").value(), + "\"cef8a5c5d7fcfb9cf601bf3a146a47e7\""); + EXPECT_TRUE(response.empty()); +} + +TEST(e2e_HTTP_Server, static_document_get_if_none_match_true) { + sourcemeta::hydra::http::ClientRequest request_1{ + std::string{HTTPSERVER_BASE_URL()} + "/static/document.json"}; + request_1.method(sourcemeta::hydra::http::Method::GET); + request_1.capture(); + sourcemeta::hydra::http::ClientResponse response_1{request_1.send().get()}; + EXPECT_EQ(response_1.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response_1.header("etag").has_value()); + EXPECT_EQ(response_1.header("etag").value(), + "\"cef8a5c5d7fcfb9cf601bf3a146a47e7\""); + EXPECT_FALSE(response_1.empty()); + + sourcemeta::hydra::http::ClientRequest request_2{ + std::string{HTTPSERVER_BASE_URL()} + "/static/document.json"}; + request_2.method(sourcemeta::hydra::http::Method::GET); + request_2.header("If-None-Match", response_1.header("etag").value()); + request_2.capture(); + sourcemeta::hydra::http::ClientResponse response_2{request_2.send().get()}; + EXPECT_EQ(response_2.status(), sourcemeta::hydra::http::Status::NOT_MODIFIED); + EXPECT_FALSE(response_2.header("etag").has_value()); + EXPECT_TRUE(response_2.empty()); +} + +TEST(e2e_HTTP_Server, static_document_get_if_none_match_false) { + sourcemeta::hydra::http::ClientRequest request{ + std::string{HTTPSERVER_BASE_URL()} + "/static/document.json"}; + request.method(sourcemeta::hydra::http::Method::GET); + request.header("If-None-Match", "\"foo\""); + request.capture(); + sourcemeta::hydra::http::ClientResponse response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-length").value(), "37"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_EQ(response.header("content-type").value(), "application/json"); + EXPECT_TRUE(response.header("last-modified").has_value()); + EXPECT_TRUE(response.header("etag").has_value()); + EXPECT_EQ(response.header("etag").value(), + "\"cef8a5c5d7fcfb9cf601bf3a146a47e7\""); + EXPECT_FALSE(response.empty()); +} + +TEST(e2e_HTTP_Server, static_image_get) { + sourcemeta::hydra::http::ClientRequest request{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request.method(sourcemeta::hydra::http::Method::GET); + request.capture(); + sourcemeta::hydra::http::ClientResponse response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-length").value(), "104593"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_EQ(response.header("content-type").value(), "image/png"); + EXPECT_TRUE(response.header("last-modified").has_value()); + EXPECT_TRUE(response.header("etag").has_value()); + EXPECT_EQ(response.header("etag").value(), + "\"81bd1728552ef6a378c090bd61427474\""); + EXPECT_FALSE(response.empty()); +} + +TEST(e2e_HTTP_Server, static_image_head) { + sourcemeta::hydra::http::ClientRequest request{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request.method(sourcemeta::hydra::http::Method::HEAD); + request.capture(); + sourcemeta::hydra::http::ClientResponse response{request.send().get()}; + EXPECT_EQ(response.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response.header("content-length").has_value()); + EXPECT_EQ(response.header("content-length").value(), "104593"); + EXPECT_TRUE(response.header("content-type").has_value()); + EXPECT_EQ(response.header("content-type").value(), "image/png"); + EXPECT_TRUE(response.header("last-modified").has_value()); + EXPECT_TRUE(response.header("etag").has_value()); + EXPECT_EQ(response.header("etag").value(), + "\"81bd1728552ef6a378c090bd61427474\""); + EXPECT_TRUE(response.empty()); +} + +TEST(e2e_HTTP_Server, static_image_if_modified_since_equal) { + sourcemeta::hydra::http::ClientRequest request_1{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request_1.capture(); + request_1.method(sourcemeta::hydra::http::Method::GET); + sourcemeta::hydra::http::ClientResponse response_1{request_1.send().get()}; + EXPECT_EQ(response_1.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response_1.header("last-modified").has_value()); + EXPECT_FALSE(response_1.empty()); + + sourcemeta::hydra::http::ClientRequest request_2{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request_2.capture(); + request_2.method(sourcemeta::hydra::http::Method::GET); + request_2.header("If-Modified-Since", + response_1.header("last-modified").value()); + sourcemeta::hydra::http::ClientResponse response_2{request_2.send().get()}; + EXPECT_EQ(response_2.status(), sourcemeta::hydra::http::Status::NOT_MODIFIED); + EXPECT_FALSE(response_2.header("last-modified").has_value()); + EXPECT_TRUE(response_2.empty()); +} + +TEST(e2e_HTTP_Server, static_image_if_modified_since_greater) { + sourcemeta::hydra::http::ClientRequest request_1{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request_1.capture(); + request_1.method(sourcemeta::hydra::http::Method::GET); + sourcemeta::hydra::http::ClientResponse response_1{request_1.send().get()}; + EXPECT_EQ(response_1.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response_1.header("last-modified").has_value()); + EXPECT_FALSE(response_1.empty()); + + auto timestamp{sourcemeta::hydra::http::from_gmt( + response_1.header("last-modified").value())}; + timestamp += std::chrono::hours{1}; + + sourcemeta::hydra::http::ClientRequest request_2{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request_2.capture(); + request_2.method(sourcemeta::hydra::http::Method::GET); + request_2.header("If-Modified-Since", + sourcemeta::hydra::http::to_gmt(timestamp)); + sourcemeta::hydra::http::ClientResponse response_2{request_2.send().get()}; + EXPECT_EQ(response_2.status(), sourcemeta::hydra::http::Status::NOT_MODIFIED); + EXPECT_FALSE(response_2.header("last-modified").has_value()); + EXPECT_TRUE(response_2.empty()); +} + +TEST(e2e_HTTP_Server, static_image_if_modified_since_less) { + sourcemeta::hydra::http::ClientRequest request_1{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request_1.capture(); + request_1.method(sourcemeta::hydra::http::Method::GET); + sourcemeta::hydra::http::ClientResponse response_1{request_1.send().get()}; + EXPECT_EQ(response_1.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response_1.header("last-modified").has_value()); + EXPECT_FALSE(response_1.empty()); + + auto timestamp{sourcemeta::hydra::http::from_gmt( + response_1.header("last-modified").value())}; + timestamp -= std::chrono::hours{1}; + + sourcemeta::hydra::http::ClientRequest request_2{ + std::string{HTTPSERVER_BASE_URL()} + "/static/image.png"}; + request_2.capture(); + request_2.method(sourcemeta::hydra::http::Method::GET); + request_2.header("If-Modified-Since", + sourcemeta::hydra::http::to_gmt(timestamp)); + sourcemeta::hydra::http::ClientResponse response_2{request_2.send().get()}; + EXPECT_EQ(response_2.status(), sourcemeta::hydra::http::Status::OK); + EXPECT_TRUE(response_2.header("last-modified").has_value()); + EXPECT_FALSE(response_2.empty()); +} diff --git a/test/e2e/httpserver/static/document.json b/test/e2e/httpserver/static/document.json new file mode 100644 index 00000000..8c850a5f --- /dev/null +++ b/test/e2e/httpserver/static/document.json @@ -0,0 +1 @@ +{ "foo": "bar" } diff --git a/test/e2e/httpserver/static/image.png b/test/e2e/httpserver/static/image.png new file mode 100644 index 00000000..6b74e70e Binary files /dev/null and b/test/e2e/httpserver/static/image.png differ diff --git a/test/e2e/httpserver/stub.cc b/test/e2e/httpserver/stub.cc index 8414beac..03325cc8 100644 --- a/test/e2e/httpserver/stub.cc +++ b/test/e2e/httpserver/stub.cc @@ -184,6 +184,20 @@ static auto on_text(const sourcemeta::hydra::http::ServerLogger &, } } +static auto +on_static(const sourcemeta::hydra::http::ServerLogger &, + const sourcemeta::hydra::http::ServerRequest &request, + sourcemeta::hydra::http::ServerResponse &response) -> void { + const std::filesystem::path file_path{STATIC_PATH + request.path().substr(7)}; + if (!std::filesystem::exists(file_path)) { + response.status(sourcemeta::hydra::http::Status::NOT_FOUND); + response.end(); + return; + } + + sourcemeta::hydra::http::serve_file(file_path, request, response); +} + static auto on_error(std::exception_ptr error, const sourcemeta::hydra::http::ServerLogger &, @@ -232,6 +246,8 @@ auto main(int argc, char *argv[]) -> int { server.route(sourcemeta::hydra::http::Method::POST, "/cache-me", on_cache_me); server.route(sourcemeta::hydra::http::Method::GET, "/etag-quoted", on_etag_quoted); + server.route(sourcemeta::hydra::http::Method::GET, "/static/*", on_static); + server.route(sourcemeta::hydra::http::Method::HEAD, "/static/*", on_static); server.otherwise(on_otherwise); server.error(on_error);