Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a general purpose static file serving utility #173

Merged
merged 1 commit into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading