Skip to content

Commit

Permalink
Implement a general purpose static file serving utility
Browse files Browse the repository at this point in the history
Signed-off-by: Juan Cruz Viotti <[email protected]>
  • Loading branch information
jviotti committed Jul 29, 2024
1 parent a7c91fb commit 93f14f6
Show file tree
Hide file tree
Showing 8 changed files with 310 additions and 1 deletion.
2 changes: 1 addition & 1 deletion src/httpserver/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
32 changes: 32 additions & 0 deletions src/httpserver/include/sourcemeta/hydra/httpserver.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

#include <cstdint> // std::uint32_t
#include <exception> // std::exception_ptr
#include <filesystem> // std::filesystem::path
#include <functional> // std::function
#include <string> // std::string
#include <tuple> // std::tuple
Expand Down Expand Up @@ -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 <sourcemeta/hydra/httpserver.h>
///
/// 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
63 changes: 63 additions & 0 deletions src/httpserver/static.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#include <sourcemeta/hydra/crypto.h>
#include <sourcemeta/hydra/httpserver.h>

#include <cassert> // assert
#include <filesystem> // std::filesystem
#include <fstream> // std::ifstream
#include <sstream> // 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<std::chrono::system_clock::duration>(
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
2 changes: 2 additions & 0 deletions test/e2e/httpserver/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
195 changes: 195 additions & 0 deletions test/e2e/httpserver/httpserver_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::ostringstream::char_type>(response.body()),
std::istreambuf_iterator<std::ostringstream::char_type>(),
std::ostreambuf_iterator<std::ostringstream::char_type>(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());
}
1 change: 1 addition & 0 deletions test/e2e/httpserver/static/document.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "foo": "bar" }
Binary file added test/e2e/httpserver/static/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions test/e2e/httpserver/stub.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 &,
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 93f14f6

Please sign in to comment.