feat: add static file serving with ETag caching
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"port": 8000,
|
"port": 8000,
|
||||||
"pipelines_dir": "./storage/pipelines/",
|
"pipelines_dir": "./storage/pipelines/",
|
||||||
"logs_dir": "./storage/logs/"
|
"logs_dir": "./storage/logs/",
|
||||||
|
"web_root": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ int main(int argc, char * argv[]) {
|
|||||||
int port = getConfigInt(config, "port", 8000);
|
int port = getConfigInt(config, "port", 8000);
|
||||||
PIString pipelinesDir = getConfigValue(config, "pipelines_dir", "./storage/pipelines/");
|
PIString pipelinesDir = getConfigValue(config, "pipelines_dir", "./storage/pipelines/");
|
||||||
PIString logsDir = getConfigValue(config, "logs_dir", "./storage/logs/");
|
PIString logsDir = getConfigValue(config, "logs_dir", "./storage/logs/");
|
||||||
|
PIString webRoot = getConfigValue(config, "web_root", "");
|
||||||
|
|
||||||
piCout << "Port: " << port;
|
piCout << "Port: " << port;
|
||||||
piCout << "Pipelines dir: " << pipelinesDir;
|
piCout << "Pipelines dir: " << pipelinesDir;
|
||||||
piCout << "Logs dir: " << logsDir;
|
piCout << "Logs dir: " << logsDir;
|
||||||
|
piCout << "Web root: " << (webRoot.isEmpty() ? "(not set)" : webRoot);
|
||||||
|
|
||||||
// Handle signals
|
// Handle signals
|
||||||
PISignals::setSlot([](PISignals::Signal s) {
|
PISignals::setSlot([](PISignals::Signal s) {
|
||||||
@@ -55,7 +57,7 @@ int main(int argc, char * argv[]) {
|
|||||||
PISignals::grabSignals(PISignals::Interrupt);
|
PISignals::grabSignals(PISignals::Interrupt);
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
Server server(static_cast<ushort>(port), pipelinesDir, logsDir);
|
Server server(static_cast<ushort>(port), pipelinesDir, logsDir, webRoot);
|
||||||
server.start();
|
server.start();
|
||||||
|
|
||||||
// Wait for exit
|
// Wait for exit
|
||||||
|
|||||||
@@ -2,14 +2,40 @@
|
|||||||
|
|
||||||
#include "messageutils.h"
|
#include "messageutils.h"
|
||||||
|
|
||||||
|
#include <pidigest.h>
|
||||||
#include <pidir.h>
|
#include <pidir.h>
|
||||||
#include <pifile.h>
|
#include <pifile.h>
|
||||||
#include <pihttpserver.h>
|
#include <pihttpserver.h>
|
||||||
|
|
||||||
Server::Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir)
|
namespace {
|
||||||
|
const PIMap<PIString, PIString> StaticContentTypes{
|
||||||
|
{"html", "text/html; charset=utf-8" },
|
||||||
|
{"htm", "text/html; charset=utf-8" },
|
||||||
|
{"css", "text/css; charset=utf-8" },
|
||||||
|
{"js", "text/javascript; charset=utf-8" },
|
||||||
|
{"mjs", "text/javascript; charset=utf-8" },
|
||||||
|
{"json", "application/json; charset=utf-8"},
|
||||||
|
{"png", "image/png" },
|
||||||
|
{"gif", "image/gif" },
|
||||||
|
{"jpeg", "image/jpeg" },
|
||||||
|
{"jpg", "image/jpeg" },
|
||||||
|
{"svg", "image/svg+xml; charset=utf-8" },
|
||||||
|
{"ico", "image/x-icon" },
|
||||||
|
{"webp", "image/webp" },
|
||||||
|
{"txt", "text/plain; charset=utf-8" },
|
||||||
|
{"pdf", "application/pdf" },
|
||||||
|
{"woff", "font/woff" },
|
||||||
|
{"woff2", "font/woff2" },
|
||||||
|
{"ttf", "font/ttf" },
|
||||||
|
{"otf", "font/otf" },
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Server::Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir, const PIString & web_root)
|
||||||
: port_(port)
|
: port_(port)
|
||||||
, pipelines_dir_(pipelines_dir)
|
, pipelines_dir_(pipelines_dir)
|
||||||
, logs_dir_(logs_dir)
|
, logs_dir_(logs_dir)
|
||||||
|
, web_root_(web_root)
|
||||||
, runner_(pipelines_dir, logs_dir) {
|
, runner_(pipelines_dir, logs_dir) {
|
||||||
httpserver_ = new PIHTTPServer();
|
httpserver_ = new PIHTTPServer();
|
||||||
|
|
||||||
@@ -40,6 +66,11 @@ bool Server::start() {
|
|||||||
httpserver_->registerPath("/api/runs/{run_id}/status", PIHTTP::Method::Get, this, &Server::getRunStatus);
|
httpserver_->registerPath("/api/runs/{run_id}/status", PIHTTP::Method::Get, this, &Server::getRunStatus);
|
||||||
httpserver_->registerPath("/api/runs/{run_id}/log", PIHTTP::Method::Get, this, &Server::getRunLog);
|
httpserver_->registerPath("/api/runs/{run_id}/log", PIHTTP::Method::Get, this, &Server::getRunLog);
|
||||||
|
|
||||||
|
// Serve static files if web_root is configured
|
||||||
|
if (!web_root_.isEmpty()) {
|
||||||
|
serveStaticFiles(web_root_);
|
||||||
|
}
|
||||||
|
|
||||||
// Unhandled request handler
|
// Unhandled request handler
|
||||||
httpserver_->registerUnhandled(this, &Server::unhandledRequest);
|
httpserver_->registerUnhandled(this, &Server::unhandledRequest);
|
||||||
|
|
||||||
@@ -48,6 +79,48 @@ bool Server::start() {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Server::serveStaticFiles(const PIString & webRoot) {
|
||||||
|
PIDir rootDir(webRoot);
|
||||||
|
piCout << "Serving static files from: " << rootDir.absolutePath();
|
||||||
|
|
||||||
|
const auto filelist = PIDir::allEntries(webRoot);
|
||||||
|
for (const auto & finfo: filelist) {
|
||||||
|
if (!finfo.isFile()) continue;
|
||||||
|
if (finfo.name().startsWith('.')) continue;
|
||||||
|
|
||||||
|
PIString relPath = finfo.dir().removeAll(webRoot) + finfo.name();
|
||||||
|
if (relPath.isEmpty() || relPath[0] != '/') {
|
||||||
|
relPath = "/" + relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
PIString ext = finfo.extension().toLowerCase();
|
||||||
|
PIString contentType = StaticContentTypes.value(ext, "application/octet-stream");
|
||||||
|
|
||||||
|
PIByteArray fileData = PIFile::readAll(finfo.path);
|
||||||
|
PIString etag = PIDigest::calculate(fileData, PIDigest::Type::BLAKE2s_128).toHex().quote();
|
||||||
|
|
||||||
|
piCout << " Registered: " << relPath << " (" << contentType << ", ETag: " << etag << ")";
|
||||||
|
|
||||||
|
auto handler = [fileData, contentType, etag](const PIHTTP::MessageConst & request) {
|
||||||
|
PIHTTP::MessageMutable msg;
|
||||||
|
msg.addHeader(PIHTTP::Header::ContentType, contentType);
|
||||||
|
msg.addHeader(PIHTTP::Header::CacheControl, "max-age=3600");
|
||||||
|
msg.addHeader(PIHTTP::Header::ETag, etag);
|
||||||
|
|
||||||
|
PIString clientEtag = request.headers().value(PIHTTP::Header::IfNoneMatch, PIString());
|
||||||
|
if (clientEtag == etag) {
|
||||||
|
msg.setCode(PIHTTP::Code::NotModified);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.setBody(fileData);
|
||||||
|
return msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
httpserver_->registerPath(relPath, PIHTTP::Method::Get, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PIHTTP::MessageMutable Server::listPipelines(const PIHTTP::MessageConst & request) {
|
PIHTTP::MessageMutable Server::listPipelines(const PIHTTP::MessageConst & request) {
|
||||||
piCout << "GET /api/pipelines";
|
piCout << "GET /api/pipelines";
|
||||||
PIVector<Pipeline> pipelines = loadPipelines(pipelines_dir_);
|
PIVector<Pipeline> pipelines = loadPipelines(pipelines_dir_);
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class Server: public PIObject {
|
|||||||
PIOBJECT(Server)
|
PIOBJECT(Server)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir);
|
Server(ushort port, const PIString & pipelines_dir, const PIString & logs_dir, const PIString & web_root = PIString());
|
||||||
~Server();
|
~Server();
|
||||||
|
|
||||||
bool start();
|
bool start();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void serveStaticFiles(const PIString & web_root);
|
||||||
|
|
||||||
PIHTTP::MessageMutable listPipelines(const PIHTTP::MessageConst & request);
|
PIHTTP::MessageMutable listPipelines(const PIHTTP::MessageConst & request);
|
||||||
PIHTTP::MessageMutable createPipeline(const PIHTTP::MessageConst & request);
|
PIHTTP::MessageMutable createPipeline(const PIHTTP::MessageConst & request);
|
||||||
PIHTTP::MessageMutable deletePipeline(const PIHTTP::MessageConst & request);
|
PIHTTP::MessageMutable deletePipeline(const PIHTTP::MessageConst & request);
|
||||||
@@ -31,6 +33,7 @@ private:
|
|||||||
ushort port_;
|
ushort port_;
|
||||||
PIString pipelines_dir_;
|
PIString pipelines_dir_;
|
||||||
PIString logs_dir_;
|
PIString logs_dir_;
|
||||||
|
PIString web_root_;
|
||||||
PIHTTPServer * httpserver_;
|
PIHTTPServer * httpserver_;
|
||||||
PipelineRunner runner_;
|
PipelineRunner runner_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ def get_free_port():
|
|||||||
class PipelineServer:
|
class PipelineServer:
|
||||||
"""Manages a pipeline-runner server instance for tests."""
|
"""Manages a pipeline-runner server instance for tests."""
|
||||||
|
|
||||||
def __init__(self, port, work_dir, verbose=False):
|
def __init__(self, port, work_dir, verbose=False, web_root=None):
|
||||||
self.port = port
|
self.port = port
|
||||||
self.work_dir = work_dir
|
self.work_dir = work_dir
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
|
self.web_root = web_root
|
||||||
|
|
||||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(test_dir)
|
project_root = os.path.dirname(test_dir)
|
||||||
@@ -37,6 +38,8 @@ class PipelineServer:
|
|||||||
"pipelines_dir": os.path.join(work_dir, "pipelines") + "/",
|
"pipelines_dir": os.path.join(work_dir, "pipelines") + "/",
|
||||||
"logs_dir": os.path.join(work_dir, "logs") + "/",
|
"logs_dir": os.path.join(work_dir, "logs") + "/",
|
||||||
}
|
}
|
||||||
|
if web_root:
|
||||||
|
config["web_root"] = web_root
|
||||||
self.config_path = os.path.join(work_dir, "pipeline-runner.conf")
|
self.config_path = os.path.join(work_dir, "pipeline-runner.conf")
|
||||||
with open(self.config_path, "w") as f:
|
with open(self.config_path, "w") as f:
|
||||||
json.dump(config, f)
|
json.dump(config, f)
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
"""Smoke tests for pipeline-runner REST API endpoints."""
|
"""Smoke tests for pipeline-runner REST API endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -24,3 +32,110 @@ def test_cors_headers(pipeline_server):
|
|||||||
"""Check that CORS headers are present on responses."""
|
"""Check that CORS headers are present on responses."""
|
||||||
response = requests.get(f"{pipeline_server.base_url}/api/pipelines")
|
response = requests.get(f"{pipeline_server.base_url}/api/pipelines")
|
||||||
assert response.headers.get("Access-Control-Allow-Origin") == "*"
|
assert response.headers.get("Access-Control-Allow-Origin") == "*"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_free_port():
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.bind(("", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def static_file_server():
|
||||||
|
"""Fixture that starts a server with web_root configured for static file tests."""
|
||||||
|
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
build_script = os.path.join(project_dir, "build.sh")
|
||||||
|
subprocess.check_call([build_script, "--fake"], cwd=project_dir)
|
||||||
|
|
||||||
|
work_dir = tempfile.mkdtemp(prefix="pipeline-static-test-")
|
||||||
|
|
||||||
|
web_root = os.path.join(work_dir, "web")
|
||||||
|
os.makedirs(web_root)
|
||||||
|
os.makedirs(os.path.join(web_root, "subdir"))
|
||||||
|
|
||||||
|
html_content = "<html><body><h1>Hello Static</h1></body></html>"
|
||||||
|
with open(os.path.join(web_root, "index.html"), "w") as f:
|
||||||
|
f.write(html_content)
|
||||||
|
|
||||||
|
css_content = "body { color: red; }"
|
||||||
|
with open(os.path.join(web_root, "style.css"), "w") as f:
|
||||||
|
f.write(css_content)
|
||||||
|
|
||||||
|
js_content = "console.log('hello');"
|
||||||
|
with open(os.path.join(web_root, "app.js"), "w") as f:
|
||||||
|
f.write(js_content)
|
||||||
|
|
||||||
|
nested_content = "nested file content"
|
||||||
|
with open(os.path.join(web_root, "subdir", "nested.txt"), "w") as f:
|
||||||
|
f.write(nested_content)
|
||||||
|
|
||||||
|
port = _get_free_port()
|
||||||
|
|
||||||
|
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
from conftest import PipelineServer
|
||||||
|
server = PipelineServer(port, work_dir, web_root=web_root)
|
||||||
|
if not server.start():
|
||||||
|
pytest.fail("Could not start pipeline-runner server with static files")
|
||||||
|
|
||||||
|
yield server, web_root
|
||||||
|
|
||||||
|
server.stop()
|
||||||
|
shutil.rmtree(work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticFiles:
|
||||||
|
def test_serve_html(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp = requests.get(f"{server.base_url}/index.html")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/html" in resp.headers.get("Content-Type", "")
|
||||||
|
assert "<h1>Hello Static</h1>" in resp.text
|
||||||
|
assert "ETag" in resp.headers
|
||||||
|
|
||||||
|
def test_serve_css(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp = requests.get(f"{server.base_url}/style.css")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/css" in resp.headers.get("Content-Type", "")
|
||||||
|
assert resp.text == "body { color: red; }"
|
||||||
|
|
||||||
|
def test_serve_js(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp = requests.get(f"{server.base_url}/app.js")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/javascript" in resp.headers.get("Content-Type", "")
|
||||||
|
assert resp.text == "console.log('hello');"
|
||||||
|
|
||||||
|
def test_serve_nested_file(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp = requests.get(f"{server.base_url}/subdir/nested.txt")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/plain" in resp.headers.get("Content-Type", "")
|
||||||
|
assert resp.text == "nested file content"
|
||||||
|
|
||||||
|
def test_etag_304(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp1 = requests.get(f"{server.base_url}/index.html")
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
etag = resp1.headers.get("ETag")
|
||||||
|
assert etag is not None
|
||||||
|
|
||||||
|
resp2 = requests.get(
|
||||||
|
f"{server.base_url}/index.html",
|
||||||
|
headers={"If-None-Match": etag},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 304
|
||||||
|
assert resp2.text == ""
|
||||||
|
|
||||||
|
def test_cache_control_header(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp = requests.get(f"{server.base_url}/index.html")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "max-age" in resp.headers.get("Cache-Control", "")
|
||||||
|
|
||||||
|
def test_static_404(self, static_file_server):
|
||||||
|
server, _ = static_file_server
|
||||||
|
resp = requests.get(f"{server.base_url}/nonexistent.html")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user