Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4139c50..743491f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -5,6 +5,7 @@ project(status_tracker LANGUAGES CXX)
include(FetchContent)
set(LIBMW_BUILD_SQLITE ON CACHE BOOL "Build libmw SQLite support")
set(LIBMW_BUILD_URL ON)
+set(LIBMW_BUILD_HTTP_SERVER ON)
FetchContent_Declare(libmw
GIT_REPOSITORY https://github.com/MetroWind/libmw.git
GIT_TAG HEAD
@@ -25,7 +26,15 @@ if(NOT TARGET SQLite3::SQLite3)
add_library(SQLite3::SQLite3 ALIAS SQLite::SQLite3)
endif()
+set(STATIC_FILES
+ "${CMAKE_CURRENT_SOURCE_DIR}/static/index.html"
+ "${CMAKE_CURRENT_SOURCE_DIR}/static/style.css"
+)
+include(cmake/embed_assets.cmake)
+
set(SOURCE_FILES
+ src/app.cpp
+ "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp"
src/data_source_sqlite.cpp
src/probe.cpp
src/socket_probe.cpp
@@ -34,6 +43,7 @@ set(SOURCE_FILES
src/thread_pool.cpp
)
set(LIBS
+ mw::http-server
mw::sqlite
mw::url
CURL::libcurl
@@ -63,6 +73,7 @@ if(BUILD_TESTING)
set(TEST_FILES
src/fake_probe.cpp
+ tests/app_test.cpp
tests/data_source_sqlite_test.cpp
tests/fake_probe_test.cpp
tests/probe_test.cpp
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
new file mode 100644
index 0000000..1155ce6
--- /dev/null
+++ b/cmake/embed_assets.cmake
@@ -0,0 +1,44 @@
+# Generate byte arrays without depending on external embedding tools.
+set(EMBEDDED_SOURCE "#include \"embedded_assets.h\"\n\nnamespace\n{\n")
+set(ASSET_ENTRIES "")
+set(ASSET_INDEX 0)
+foreach(ASSET IN LISTS STATIC_FILES)
+ set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ASSET}")
+ file(READ "${ASSET}" ASSET_HEX HEX)
+ string(LENGTH "${ASSET_HEX}" ASSET_SIZE)
+ math(EXPR ASSET_SIZE "${ASSET_SIZE} / 2")
+ string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," ASSET_BYTES "${ASSET_HEX}")
+ string(APPEND EMBEDDED_SOURCE
+ "const unsigned char ASSET_${ASSET_INDEX}[] = {${ASSET_BYTES}0};\n")
+ file(RELATIVE_PATH ASSET_NAME "${CMAKE_CURRENT_SOURCE_DIR}/static"
+ "${ASSET}")
+ get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
+ if(ASSET_EXTENSION STREQUAL ".html")
+ set(ASSET_TYPE "text/html; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".css")
+ set(ASSET_TYPE "text/css; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".js")
+ set(ASSET_TYPE "text/javascript; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".svg")
+ set(ASSET_TYPE "image/svg+xml")
+ else()
+ set(ASSET_TYPE "application/octet-stream")
+ endif()
+ if(ASSET_NAME STREQUAL "index.html")
+ set(ASSET_PATH "/")
+ else()
+ set(ASSET_PATH "/static/${ASSET_NAME}")
+ endif()
+ string(APPEND ASSET_ENTRIES
+ " {\"${ASSET_PATH}\", \"${ASSET_TYPE}\", {reinterpret_cast<const char*>(ASSET_${ASSET_INDEX}), ${ASSET_SIZE}}},\n")
+ math(EXPR ASSET_INDEX "${ASSET_INDEX} + 1")
+endforeach()
+string(APPEND EMBEDDED_SOURCE
+ "const EmbeddedAsset ASSETS[] = {\n${ASSET_ENTRIES}};\n}\n\n"
+ "std::span<const EmbeddedAsset> embeddedAssets()\n{\n return ASSETS;\n}\n")
+file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
+file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
+ "${EMBEDDED_SOURCE}")
+configure_file(
+ "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
+ "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp" COPYONLY)
diff --git a/src/app.cpp b/src/app.cpp
new file mode 100644
index 0000000..f54e849
--- /dev/null
+++ b/src/app.cpp
@@ -0,0 +1,38 @@
+#include "app.h"
+
+#include <exception>
+#include <string>
+
+#include "embedded_assets.h"
+
+App::App(const ListenAddress& listen) : mw::HTTPServer(listen)
+{}
+
+void App::setup()
+{
+ server.set_exception_handler(unexpectedException);
+ server.Get("/.*", serveStatic);
+}
+
+void App::serveStatic(const Request& request, Response& response)
+{
+ for(const auto& asset : embeddedAssets())
+ {
+ if(request.path == asset.path)
+ {
+ response.set_content(asset.content.data(), asset.content.size(),
+ std::string(asset.content_type));
+ return;
+ }
+ }
+ response.status = 404;
+ response.set_content("Not found\n", "text/plain; charset=utf-8");
+}
+
+void App::unexpectedException([[maybe_unused]] const Request& request,
+ [[maybe_unused]] Response& response,
+ [[maybe_unused]] std::exception_ptr exception)
+ noexcept
+{
+ std::terminate();
+}
diff --git a/src/app.h b/src/app.h
new file mode 100644
index 0000000..9b50cae
--- /dev/null
+++ b/src/app.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <exception>
+
+#include <mw/http_server.hpp>
+
+/// HTTP application serving the status tracker UI.
+class App : public mw::HTTPServer
+{
+public:
+ /// Configure the listening address. Call start() to begin serving.
+ explicit App(const ListenAddress& listen);
+
+protected:
+ /// Register HTTP routes before the server starts.
+ void setup() override;
+
+private:
+ static void serveStatic(const Request& request, Response& response);
+ static void unexpectedException(const Request& request,
+ Response& response,
+ std::exception_ptr exception) noexcept;
+};
diff --git a/src/embedded_assets.h b/src/embedded_assets.h
new file mode 100644
index 0000000..a22f04b
--- /dev/null
+++ b/src/embedded_assets.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <span>
+#include <string_view>
+
+/// One HTTP resource stored in the executable.
+struct EmbeddedAsset
+{
+ /// Exact request path for this resource.
+ std::string_view path;
+ /// HTTP content type, including charset for text resources.
+ std::string_view content_type;
+ /// Resource bytes, which may contain null bytes.
+ std::string_view content;
+};
+
+/// Access static resources with storage lasting for the process lifetime.
+std::span<const EmbeddedAsset> embeddedAssets();
diff --git a/src/main.cpp b/src/main.cpp
index d4fc6ec..4e0ee58 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,8 +1,19 @@
#include <iostream>
+#include "app.h"
+
// Entry point for the status tracker service.
int main()
{
- std::cout << "Status Tracker\n";
+ App app(mw::IPSocketInfo{"127.0.0.1", 8080});
+ auto started = app.start();
+ if(!started)
+ {
+ std::cerr << started.error().msg() << '\n';
+ return 1;
+ }
+ std::cout << "Status Tracker listening on http://127.0.0.1:8080\n"
+ << std::flush;
+ app.wait();
return 0;
}
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..ef3045b
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Status Tracker</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <main>
+ <h1>Status Tracker</h1>
+ <p>Service status will appear here.</p>
+ </main>
+</body>
+</html>
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..6afb634
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,12 @@
+:root
+{
+ font-family: system-ui, sans-serif;
+ color-scheme: light dark;
+}
+
+main
+{
+ max-width: 60rem;
+ margin: 3rem auto;
+ padding: 0 1rem;
+}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
new file mode 100644
index 0000000..8495cad
--- /dev/null
+++ b/tests/app_test.cpp
@@ -0,0 +1,86 @@
+#include "app.h"
+#include "embedded_assets.h"
+
+#include <thread>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TestApp : public App
+{
+public:
+ TestApp() : App(mw::IPSocketInfo{"127.0.0.1", 0}) {}
+
+ int listen()
+ {
+ setup();
+ const int port = server.bind_to_any_port("127.0.0.1");
+ if(port >= 0)
+ {
+ worker = std::thread(&TestApp::serve, this);
+ server.wait_until_ready();
+ }
+ return port;
+ }
+
+ ~TestApp()
+ {
+ server.stop();
+ if(worker.joinable())
+ {
+ worker.join();
+ }
+ }
+
+private:
+ void serve()
+ {
+ server.listen_after_bind();
+ }
+
+ std::thread worker;
+};
+
+TEST(App, ServesEmbeddedAssets)
+{
+ TestApp app;
+ const int port = app.listen();
+ ASSERT_GT(port, 0);
+ httplib::Client client("127.0.0.1", port);
+ for(const auto& asset : embeddedAssets())
+ {
+ auto response = client.Get(std::string(asset.path) + "?v=1");
+ ASSERT_TRUE(response);
+ EXPECT_EQ(response->status, 200);
+ EXPECT_EQ(response->body, asset.content);
+ EXPECT_EQ(response->get_header_value("Content-Type"),
+ asset.content_type);
+ auto head = client.Head(std::string(asset.path));
+ ASSERT_TRUE(head);
+ EXPECT_EQ(head->status, 200);
+ EXPECT_TRUE(head->body.empty());
+ EXPECT_EQ(head->get_header_value("Content-Length"),
+ std::to_string(asset.content.size()));
+ }
+}
+
+TEST(App, RejectsUnknownPathsAndWrites)
+{
+ TestApp app;
+ const int port = app.listen();
+ ASSERT_GT(port, 0);
+ httplib::Client client("127.0.0.1", port);
+ for(const auto* path : {"/missing", "/static/missing.css", "/prd.md"})
+ {
+ auto response = client.Get(path);
+ ASSERT_TRUE(response);
+ EXPECT_EQ(response->status, 404);
+ }
+ auto response = client.Post("/static/style.css", "replace", "text/plain");
+ ASSERT_TRUE(response);
+ EXPECT_NE(response->status, 200);
+}
+
+}