Skip to content

Commit 25adf48

Browse files
committed
Merge TASK-052 (Hook bus doc/example/bench/stress closeout) into feature/v2.0
2 parents 63a8fda + 4460479 commit 25adf48

24 files changed

Lines changed: 1487 additions & 166 deletions

Makefile.am

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ endif
4040

4141
EXTRA_DIST = libhttpserver.pc.in $(DX_CONFIG) scripts/extract-release-notes.sh scripts/validate-version.sh \
4242
scripts/check-examples.sh scripts/check-readme.sh scripts/check-release-notes.sh \
43-
scripts/check-doxygen.sh \
43+
scripts/check-doxygen.sh scripts/check-hooks-doc-spotcheck.sh \
4444
scripts/check-soversion.sh scripts/check-parallel-install.sh \
4545
scripts/check-complexity.sh scripts/check-duplication.sh \
4646
scripts/check-file-size.sh \
@@ -287,7 +287,7 @@ check-hygiene:
287287
# shared staged install to avoid paying two full `make install` costs on
288288
# every `make check`. Both sub-checks can still be invoked standalone (they
289289
# will do their own install when CHECK_*_SHARED is not set).
290-
check-local: check-headers check-examples check-readme check-release-notes check-doxygen
290+
check-local: check-headers check-examples check-readme check-release-notes check-doxygen check-hooks-doc-spotcheck
291291
@echo "=== Shared staged install for check-install-layout and check-hygiene ==="
292292
@rm -rf $(abs_top_builddir)/.shared-check-stage
293293
@$(MAKE) $(AM_MAKEFLAGS) install DESTDIR=$(abs_top_builddir)/.shared-check-stage >check-shared-install.log 2>&1 || { \
@@ -331,6 +331,16 @@ check-doxygen:
331331
@echo "=== check-doxygen: enforce TASK-043 zero-warning invariant ==="
332332
@BUILD_DIR="$(abs_top_builddir)" $(top_srcdir)/scripts/check-doxygen.sh
333333

334+
# TASK-052: hook-bus documentation spotcheck (per-header @file/@brief blocks,
335+
# eleven-phase enumeration on webserver::add_hook, five-permitted /
336+
# six-rejected enumeration on http_resource::add_hook, alias callouts on the
337+
# five v1 setters, hook-concurrency sentence in the class-level threading
338+
# block). Static grep-based gate; complements check-doxygen's full Doxygen
339+
# warning sweep.
340+
check-hooks-doc-spotcheck:
341+
@echo "=== check-hooks-doc-spotcheck: enforce TASK-052 invariants on hook public surface ==="
342+
@$(top_srcdir)/scripts/check-hooks-doc-spotcheck.sh
343+
334344
# TASK-044: SOVERSION acceptance gate. Stages a clean DESTDIR install and
335345
# asserts that the v2.0 on-disk SONAME layout is correct (libhttpserver.so.2
336346
# on Linux / libhttpserver.2.dylib on Darwin, pkg-config --modversion 2.0.0,
@@ -375,7 +385,7 @@ lint-file-size:
375385
@echo "=== lint-file-size: enforce per-file line-count ceiling ==="
376386
@$(top_srcdir)/scripts/check-file-size.sh
377387

378-
.PHONY: check-headers check-install-layout check-hygiene check-examples check-readme check-release-notes check-doxygen check-soversion check-parallel-install lint-complexity lint-duplication lint-file-size
388+
.PHONY: check-headers check-install-layout check-hygiene check-examples check-readme check-release-notes check-doxygen check-hooks-doc-spotcheck check-soversion check-parallel-install lint-complexity lint-duplication lint-file-size
379389

380390
# TASK-039: top-level convenience rule that descends into test/ to
381391
# build and run the bench binaries (see test/Makefile.am and

README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ The block above is reproduced byte-for-byte from
9595
* [WebSocket support](#websocket-support)
9696
* [Daemon introspection and external event loops](#daemon-introspection-and-external-event-loops)
9797
* [HTTP utils](#http-utils)
98+
* [Lifecycle hooks](#lifecycle-hooks)
9899
* [Threading contract](#threading-contract)
99100
* [Error propagation](#error-propagation)
100101
* [Feature availability](#feature-availability)
@@ -1510,6 +1511,93 @@ The following utility functions are available on `http::http_utils`:
15101511

15111512
[Back to TOC](#table-of-contents)
15121513

1514+
## Lifecycle hooks
1515+
1516+
The hook bus is the single extension surface for observing or
1517+
short-circuiting the request lifecycle. It replaces v1's single-slot
1518+
patchwork (one `log_access` callback, one `not_found_handler`, one
1519+
`method_not_allowed_handler`, one `internal_error_handler`, one
1520+
`auth_handler`) with eleven distinct phases spanning connection,
1521+
request, routing, handler, and response. Each phase accepts multiple
1522+
subscribers and is observable both server-wide (via `webserver::add_hook`)
1523+
and, for the five post-route-resolution phases, per-route (via
1524+
`http_resource::add_hook`). The contract is captured in DR-012 and
1525+
[`specs/architecture/04-components/hooks.md`](specs/architecture/04-components/hooks.md).
1526+
1527+
Each call returns a move-only `hook_handle`. The handle's destructor
1528+
removes the registration; `hook_handle::detach()` disarms the destructor
1529+
so the registration persists for the webserver's lifetime.
1530+
1531+
### Phases
1532+
1533+
| Phase | Fires at | Short-circuit | Per-route eligible |
1534+
|-------|----------|---------------|--------------------|
1535+
| `connection_opened` | New TCP / TLS connection accepted by MHD | No | No |
1536+
| `accept_decision` | After the default-policy / block-list verdict; the connection has been accepted or denied | No | No |
1537+
| `connection_closed` | Connection torn down (peer close or server close) | No | No |
1538+
| `request_received` | Request line and headers parsed, body not yet consumed | Yes (`hook_action`) | No |
1539+
| `body_chunk` | Each upload-body chunk delivered by MHD | Yes (`hook_action`) | No |
1540+
| `route_resolved` | After URL → resource resolution; carries the matched route or "no match" | No | No |
1541+
| `before_handler` | After route resolution and method check, immediately before the handler runs | Yes (`hook_action`) | Yes |
1542+
| `handler_exception` | Exception escapes the handler, before `internal_error_handler` is consulted | Yes (`hook_action`) | Yes |
1543+
| `after_handler` | Handler returned a response, before it is queued on the wire (mutation point) | Yes (`hook_action`) | Yes |
1544+
| `response_sent` | Response handed to `MHD_queue_response`; carries `status`, `bytes_queued`, `elapsed` | No | Yes |
1545+
| `request_completed` | Request lifecycle finished (success or failure); last hook to fire per request | No | Yes |
1546+
1547+
### Short-circuit semantics
1548+
1549+
Phases marked "Short-circuit" return a `hook_action`:
1550+
`hook_action::pass()` lets the chain continue;
1551+
`hook_action::respond_with(response)` aborts the chain at that
1552+
position. The wrapped response is sent on the wire in place of any
1553+
handler output. Subsequent hooks in the same phase are not invoked.
1554+
Observation-only phases (`connection_opened`, `accept_decision`,
1555+
`connection_closed`, `route_resolved`, `response_sent`,
1556+
`request_completed`) ignore the return; the chain always runs to
1557+
completion.
1558+
1559+
### Per-route hooks
1560+
1561+
`http_resource::add_hook(phase, fn)` accepts only the five
1562+
post-route-resolution phases: `before_handler`, `handler_exception`,
1563+
`after_handler`, `response_sent`, `request_completed`. Per-route hooks
1564+
fire after the server-wide chain at the same phase, and only if that
1565+
server-wide chain did not short-circuit. Passing any other phase
1566+
throws `std::invalid_argument` naming the rejected phase. See
1567+
[`examples/per_route_auth.cpp`](examples/per_route_auth.cpp) for a
1568+
worked example.
1569+
1570+
### Examples
1571+
1572+
* [`examples/banned_ip_log.cpp`](examples/banned_ip_log.cpp) — log every
1573+
banned-IP rejection via a single `accept_decision` hook.
1574+
* [`examples/early_413.cpp`](examples/early_413.cpp) — return 413 from
1575+
a `request_received` hook before the body is consumed.
1576+
* [`examples/clf_access_log.cpp`](examples/clf_access_log.cpp) — write
1577+
a Common Log Format access line from a `response_sent` hook.
1578+
* [`examples/per_route_auth.cpp`](examples/per_route_auth.cpp)
1579+
per-route HTTP Basic auth via `http_resource::add_hook(before_handler, ...)`.
1580+
1581+
### v1 aliases
1582+
1583+
Each of the v1 single-slot setters is an alias for an `add_hook` call
1584+
at the corresponding phase. The aliases survive for ergonomic call
1585+
sites; new code can use either form.
1586+
1587+
| v1 setter | Equivalent `add_hook` call |
1588+
|-----------|----------------------------|
1589+
| `log_access(fn)` | `ws.add_hook(hook_phase::response_sent, fn)` |
1590+
| `not_found_handler(fn)` | `ws.add_hook(hook_phase::route_resolved, fn)` |
1591+
| `method_not_allowed_handler(fn)` | `ws.add_hook(hook_phase::before_handler, fn)` |
1592+
| `internal_error_handler(fn)` | `ws.add_hook(hook_phase::handler_exception, fn)` |
1593+
| `auth_handler(fn)` | `ws.add_hook(hook_phase::before_handler, fn)` |
1594+
1595+
The aliases install observation-stub hooks under the same dispatch
1596+
plumbing, so the on-the-wire behaviour is identical regardless of
1597+
which form the caller used.
1598+
1599+
[Back to TOC](#table-of-contents)
1600+
15131601
## Threading contract
15141602

15151603
Distilled from
@@ -1717,6 +1805,21 @@ to the canonical example for each topic covered in this manual.
17171805
* [`examples/client_cert_auth.cpp`](examples/client_cert_auth.cpp)
17181806
mutual TLS with X.509 client certificates.
17191807

1808+
### Lifecycle hooks
1809+
1810+
* [`examples/banned_ip_log.cpp`](examples/banned_ip_log.cpp) — log every
1811+
banned-IP rejection from a single `accept_decision` hook (issue #332).
1812+
* [`examples/early_413.cpp`](examples/early_413.cpp) — short-circuit
1813+
oversized uploads with 413 before any body bytes are consumed via a
1814+
`request_received` hook (issue #273).
1815+
* [`examples/clf_access_log.cpp`](examples/clf_access_log.cpp)
1816+
Common Log Format access logger written as a `response_sent` hook
1817+
using the structured `status` / `bytes_queued` / `elapsed` context
1818+
fields (issues #281 and #69).
1819+
* [`examples/per_route_auth.cpp`](examples/per_route_auth.cpp)
1820+
per-route HTTP Basic auth via `http_resource::add_hook(before_handler,
1821+
...)` that does not touch sibling routes (DR-012).
1822+
17201823
### Operations
17211824

17221825
* [`examples/daemon_info.cpp`](examples/daemon_info.cpp) — introspect

RELEASE_NOTES.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,25 @@ v1.x is end-of-life on the day v2.0 ships.
101101
uses `method_set`.
102102
- **`httpserver::constants` namespace.** `constexpr` replacements for
103103
every removed `#define`.
104-
- **Lifecycle hook bus (M5).** `ws.add_hook(hook_phase, std::function)`
105-
registers observation/mutation hooks on the request/connection
106-
lifecycle. The three connection-level phases — `connection_opened`,
107-
`accept_decision`, `connection_closed` — fire on every connection;
108-
`accept_decision` exposes `{peer, accepted, reason}` (closes #332,
109-
see `examples/banned_ip_log.cpp`). The other eight phases land in
110-
later M5 tasks.
104+
- **Lifecycle hook bus (`webserver::add_hook` / `http_resource::add_hook`).**
105+
Eleven phases (`hook_phase` enum: `connection_opened`,
106+
`accept_decision`, `connection_closed`, `request_received`,
107+
`body_chunk`, `route_resolved`, `before_handler`,
108+
`handler_exception`, `after_handler`, `response_sent`,
109+
`request_completed`) spanning connection, request, routing, handler,
110+
and response. Multi-subscriber, server-wide and per-route. The v1
111+
single-slot setters (`log_access`, `not_found_handler`,
112+
`method_not_allowed_handler`, `internal_error_handler`,
113+
`auth_handler`) survive as documented aliases for the corresponding
114+
`add_hook` calls. Each registration returns a move-only `hook_handle`
115+
whose destructor erases the entry; `hook_handle::detach()` keeps the
116+
registration alive for the webserver's lifetime. See
117+
`specs/architecture/04-components/hooks.md` and
118+
[`README.md#lifecycle-hooks`](README.md#lifecycle-hooks). Closes:
119+
#332 (banned-IP log, `examples/banned_ip_log.cpp`), #281 + #69
120+
(CLF / time-taken access log, `examples/clf_access_log.cpp`), #273
121+
(early 413, `examples/early_413.cpp`); partially closes #272
122+
(per-chunk observation; body steal deferred to v2.1).
111123

112124
## What's renamed (v1 → v2)
113125

examples/Makefile.am

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
LDADD = $(top_builddir)/src/libhttpserver.la
2020
AM_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/httpserver/
2121
METASOURCES = AUTO
22-
noinst_PROGRAMS = hello_world shared_state service custom_error allowing_disallowing_methods handlers hello_with_get_arg args_processing setting_headers custom_access_log clf_access_log minimal_https minimal_file_response minimal_deferred url_registration minimal_ip_ban banned_ip_log early_413 benchmark_select benchmark_threads benchmark_nodelay deferred_with_accumulator file_upload file_upload_with_callback empty_response_example iovec_response_example pipe_response_example daemon_info external_event_loop turbo_mode binary_buffer_response
22+
noinst_PROGRAMS = hello_world shared_state service custom_error allowing_disallowing_methods handlers hello_with_get_arg args_processing setting_headers custom_access_log clf_access_log minimal_https minimal_file_response minimal_deferred url_registration minimal_ip_ban banned_ip_log early_413 per_route_auth benchmark_select benchmark_threads benchmark_nodelay deferred_with_accumulator file_upload file_upload_with_callback empty_response_example iovec_response_example pipe_response_example daemon_info external_event_loop turbo_mode binary_buffer_response
2323

2424
hello_world_SOURCES = hello_world.cpp
2525
shared_state_SOURCES = shared_state.cpp
@@ -45,6 +45,11 @@ url_registration_SOURCES = url_registration.cpp
4545
minimal_ip_ban_SOURCES = minimal_ip_ban.cpp
4646
banned_ip_log_SOURCES = banned_ip_log.cpp
4747
early_413_SOURCES = early_413.cpp
48+
# TASK-052: per-route auth via http_resource::add_hook(before_handler).
49+
# Demonstrates that a hook registered on one resource fires only when
50+
# that resource is dispatched -- a private route can require credentials
51+
# without touching sibling routes (DR-012).
52+
per_route_auth_SOURCES = per_route_auth.cpp
4853
benchmark_select_SOURCES = benchmark_select.cpp
4954
benchmark_threads_SOURCES = benchmark_threads.cpp
5055
benchmark_nodelay_SOURCES = benchmark_nodelay.cpp

examples/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ TLS and authentication
7070
* `client_cert_auth.cpp` — mutual TLS with X.509 client certificates.
7171
Ships as a documentation artifact; not wired into `noinst_PROGRAMS`.
7272

73+
Lifecycle hooks
74+
---------------
75+
76+
The hook bus (DR-012, §4.10) lets user code observe and short-circuit
77+
every interesting point in the request lifecycle. The four examples
78+
below demonstrate the user-visible resolution of issues #332, #273,
79+
#281, #69, and the per-route hook contract introduced in TASK-051.
80+
81+
* `banned_ip_log.cpp` — a single `accept_decision` hook logs every
82+
banned-IP rejection to stderr. Resolves issue #332.
83+
* `early_413.cpp` — a single `request_received` hook returns
84+
`respond_with(http_response::empty().with_status(413))` when
85+
`Content-Length` exceeds the configured cap. The request body never
86+
crosses the I/O boundary. Resolves issue #273.
87+
* `clf_access_log.cpp` — a single `response_sent` hook formats a
88+
Common Log Format line using the structured context fields
89+
(`status`, `bytes_queued`, `elapsed`). Resolves issues #281 and #69.
90+
* `per_route_auth.cpp``http_resource::add_hook(before_handler, ...)`
91+
guards a single route with HTTP Basic auth without touching sibling
92+
routes. Demonstrates per-route hook scoping (DR-012).
93+
7394
Operations
7495
----------
7596

examples/per_route_auth.cpp

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Demonstrates DR-012 / PRD-HOOK-REQ-006: a `before_handler` hook
2+
// registered via `http_resource::add_hook(...)` is per-route. The hook
3+
// fires only when *this* resource is dispatched -- a sibling route
4+
// registered on the same webserver is not touched.
5+
//
6+
// The example wires two resources:
7+
//
8+
// /public -- always 200 OK, no auth.
9+
// /private -- a per-route before_handler hook checks the Authorization
10+
// header (HTTP Basic). Missing or invalid credentials
11+
// short-circuit with 401; valid credentials let the
12+
// handler run and return 200 OK.
13+
//
14+
// SECURITY NOTE (CWE-798): the expected credentials are loaded from the
15+
// environment (AUTH_USER / AUTH_PASS); do not hardcode them. Set both
16+
// before running:
17+
//
18+
// export AUTH_USER=alice AUTH_PASS=hunter2
19+
// ./per_route_auth
20+
//
21+
// Then:
22+
//
23+
// curl -v http://localhost:8080/public # 200 OK
24+
// curl -v http://localhost:8080/private # 401
25+
// curl -v -u alice:hunter2 http://localhost:8080/private # 200 OK
26+
//
27+
// Contrast with `centralized_authentication.cpp`, which installs the
28+
// server-wide `auth_handler` setter (the v1 alias for a webserver-wide
29+
// before_handler hook). Per-route `add_hook` is the right shape when
30+
// only a subset of routes need protection without dragging the rest of
31+
// the surface through an auth_skip_paths list.
32+
33+
#include <cstdlib>
34+
#include <functional>
35+
#include <iostream>
36+
#include <memory>
37+
#include <string>
38+
#include <string_view>
39+
40+
#include <httpserver.hpp>
41+
42+
namespace hs = httpserver;
43+
44+
namespace {
45+
46+
class hello_resource : public hs::http_resource {
47+
public:
48+
hs::http_response render_get(const hs::http_request&) override {
49+
return hs::http_response::string("Hello, World!");
50+
}
51+
};
52+
53+
class private_resource : public hs::http_resource {
54+
public:
55+
hs::http_response render_get(const hs::http_request&) override {
56+
return hs::http_response::string("Welcome to the private area.");
57+
}
58+
};
59+
60+
} // namespace
61+
62+
int main() {
63+
const char* expected_user = std::getenv("AUTH_USER");
64+
const char* expected_pass = std::getenv("AUTH_PASS");
65+
if (expected_user == nullptr || expected_pass == nullptr) {
66+
std::cerr << "per_route_auth: set AUTH_USER and AUTH_PASS before "
67+
"running.\n";
68+
return 1;
69+
}
70+
71+
hs::webserver ws{hs::create_webserver(8080)};
72+
73+
auto pub = std::make_shared<hello_resource>();
74+
auto priv = std::make_shared<private_resource>();
75+
76+
// The hook is registered on `priv` ONLY. Dispatches against `pub`
77+
// never enter this callback -- a sibling-route observation that is
78+
// the load-bearing property of per-route hooks (DR-012).
79+
std::string user{expected_user};
80+
std::string pass{expected_pass};
81+
auto h = priv->add_hook(hs::hook_phase::before_handler,
82+
std::function<hs::hook_action(hs::before_handler_ctx&)>(
83+
[user, pass](hs::before_handler_ctx& ctx) {
84+
if (ctx.request == nullptr) {
85+
return hs::hook_action::pass();
86+
}
87+
std::string_view u = ctx.request->get_user();
88+
std::string_view p = ctx.request->get_pass();
89+
if (u == user && p == pass) {
90+
return hs::hook_action::pass();
91+
}
92+
return hs::hook_action::respond_with(
93+
hs::http_response::unauthorized(
94+
"Basic", "private-realm", "Unauthorized"));
95+
}));
96+
97+
// Keep the handle in scope for the full server lifetime; its
98+
// destructor at scope exit removes the registration. Since main()
99+
// never returns (start(true) blocks), this is the simple shape.
100+
101+
ws.register_path("/public", pub);
102+
ws.register_path("/private", priv);
103+
104+
ws.start(true); // blocking
105+
return 0;
106+
}

scripts/check-examples.sh

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,5 +174,33 @@ if [ "$unlisted" -gt 0 ]; then
174174
fail "$unlisted .cpp file(s) in examples/ are not listed in Makefile.am noinst_PROGRAMS — add them or add to KNOWN_ARTIFACTS"
175175
fi
176176

177-
echo "check-examples: OK (hello_world.cpp = $loc LOC; shared_state.cpp asserted; Makefile.am coverage verified bidirectionally)"
177+
# ---- TASK-052: hook-example documentation coverage --------------------------
178+
# The four lifecycle-hook examples (banned_ip_log, early_413, clf_access_log,
179+
# per_route_auth) must be listed in both examples/README.md and the top-level
180+
# README.md so the user-visible resolution of issues #332, #281, #69, #273 is
181+
# discoverable. Per TASK-052 / Phase 3.
182+
EXAMPLES_README="$REPO_ROOT/examples/README.md"
183+
TOP_README="$REPO_ROOT/README.md"
184+
HOOK_EXAMPLES="banned_ip_log early_413 clf_access_log per_route_auth"
185+
186+
for f in "$EXAMPLES_README" "$TOP_README"; do
187+
[ -f "$f" ] || fail "$(basename "$f") does not exist"
188+
done
189+
190+
missing_doc=0
191+
for ex in $HOOK_EXAMPLES; do
192+
if ! grep -q "${ex}\\.cpp" "$EXAMPLES_README"; then
193+
echo "check-examples: FAIL: examples/README.md does not mention ${ex}.cpp" >&2
194+
missing_doc=$((missing_doc + 1))
195+
fi
196+
if ! grep -q "${ex}\\.cpp" "$TOP_README"; then
197+
echo "check-examples: FAIL: README.md does not mention ${ex}.cpp" >&2
198+
missing_doc=$((missing_doc + 1))
199+
fi
200+
done
201+
if [ "$missing_doc" -gt 0 ]; then
202+
fail "$missing_doc hook-example reference(s) missing from README docs (TASK-052)"
203+
fi
204+
205+
echo "check-examples: OK (hello_world.cpp = $loc LOC; shared_state.cpp asserted; Makefile.am coverage verified bidirectionally; hook examples listed in both READMEs)"
178206
exit 0

0 commit comments

Comments
 (0)