coverage~bigbes/sr-ht-ecore3bd158fbchimw/chimw.go

Coverage
100.0% 8/8 statements
Δ
Blob
c847113
Uncovered nothing — every instrumented line ran
1 // Package chimw is the chi half of the shared HTTP middleware for the custom
2 // services of a self-hosted SourceHut instance (compare, spec, dolt, cover,
3 // bench, tokens).
4 //
5 // It is a second middleware package because the first one may not grow these.
6 // sr-ht-ecore/middleware imports nothing but net/http, on purpose: it is the
7 // package a handler, an API surface or a plain http.ServeMux can reach for
8 // without inheriting anybody's router. That rule is what kept the donors' getHead
9 // out of it — three lines, all three of them chi's — and the rule is right. It is
10 // not, however, an argument for leaving the helper copied into every router that
11 // wants it. Every custom service on this instance routes with chi; a package
12 // that says so in its name costs a service that already imports chi exactly
13 // nothing, and costs middleware's rule nothing either.
14 //
15 // The boundary between the two is one question: does the helper have to know
16 // what a route is? A cache header, a panic guard and a status constant do not,
17 // and live in middleware. Registering a pair of methods for one pattern,
18 // installing the two handlers chi calls when routing fails, and reading the
19 // request id chi's RequestID middleware put in the context all do, and live
20 // here.
21 //
22 // Three things, each of them either copied identically by the services that have
23 // it or absent from the ones that do not — which is the same finding twice over:
24 //
25 // - GetHead, the read route registered under GET and HEAD both. Five copies:
26 // cover, bench and tokens on their web surface, cover and bench again on
27 // their API, where the same three lines carry a different name each time.
28 // - RenderRefusals, chi's NotFound and MethodNotAllowed pointed at the
29 // service's own error page. spec and dolt install neither, so an unrouted GET
30 // there answers with net/http's plain-text 404 while every other refusal on
31 // the instance is a rendered page.
32 // - RequestLogger, the request line as a slog record instead of chi's
33 // stdlib-log line on stdout.
34 //
35 // Usage, in the order a service installs them:
36 //
37 // r.Use(chimiddleware.RequestID)
38 // r.Use(chimiddleware.RealIP)
39 // r.Use(chimw.RequestLogger(chimw.SlogFormatter{Skip: chimw.SkipPaths("/healthz")}))
40 // r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
41 // s.renderError(w, r, http.StatusInternalServerError, pages.InternalMessage)
42 // }))
43 // r.Use(middleware.PrivateCache)
44 //
45 // chimw.RenderRefusals(r, s.renderError)
46 // chimw.GetHead(r, "/healthz", s.handleHealthz)
47 //
48 // The daemons that adopt this will have to rename an import: every one of them
49 // already aliases chi's own middleware package as chimw, which is exactly the
50 // name this one takes by default. chimiddleware, as spelled above and in this
51 // package's own files, is the alias to move them to — the shorter name belongs
52 // to the package a service writes against.
53 //
54 // The request logger is the one middleware that belongs outside
55 // middleware.RecoverPanics rather than inside it. It has to observe the status
56 // that actually went out, and for a panicking handler that status is the 500
57 // RecoverPanics renders — a logger installed underneath would see the unwinding
58 // stack and nothing written, and would log the request that produced the error
59 // page as if it had produced nothing. chi's own Logger carries the same
60 // instruction for the same reason. Nothing is left uncovered by moving it out:
61 // this middleware builds a record and calls slog, and a panic in slog is not a
62 // failure any error page is going to survive either.
63 //
64 // Records go to slog's default logger unless one is named, exactly as
65 // middleware.RecoverPanics does and for the same reason: a library has no
66 // business choosing a handler. The service installs its own — scribe's tinted
67 // one on this instance — with slog.SetDefault at startup, and the request lines
68 // land in the same stream, with the same masking rules, as everything else it
69 // logs.
70 package chimw
71
72 import (
73 "net/http"
74
75 "github.com/go-chi/chi/v5"
76
77 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
78 )
79
80 // GetHead registers one read route under GET and HEAD both.
81 //
82 // chi's Get registers GET alone, and net/http synthesizes nothing for a custom
83 // handler, so every read route of a donor answered `curl -I` — and every uptime
84 // probe, and every cache revalidating what it holds — with a 405 naming GET as
85 // the only method it would accept, plus a kilobyte of rendered error page from a
86 // path whose whole job is to say "yes" cheaply. RFC 9110 §9.3.2 makes HEAD
87 // mandatory for any resource that serves GET, and HEAD is the one method a
88 // monitor reaches for precisely because it costs no body.
89 //
90 // The two share the handler rather than getting one each, and that is the whole
91 // design: nothing in these services looks at r.Method on a read path, so a HEAD
92 // produces exactly the status line and the headers its GET would have, computed
93 // by the same code on the same query. There is no second path that could answer
94 // 200 where the GET answers 404, which on these pages would be a visibility leak
95 // (SPEC ch. 6.3). The body is dropped by net/http, which discards writes on a
96 // HEAD response after counting them, so the Content-Length a client gets is the
97 // real one.
98 //
99 // It is this and not chi's own middleware.GetHead, which registers nothing and
100 // instead rewrites, per request, the method the routing tree is walked with.
101 //
102 // The donors' comments say that rewrite lands on r.Method, so that every line
103 // written about the request — the log record of RequestLogger below, the panic
104 // report of middleware.RecoverPanics — names a method the viewer did not send.
105 // Read chi v5's source and it does not: it sets RouteMethod on the route context
106 // and leaves the request alone. The correction is worth keeping written down,
107 // because what is left is the half of the objection that no ordering or logging
108 // discipline can work around.
109 //
110 // The tree stops describing the service. A route registered for GET alone is a
111 // route that does not serve HEAD as far as anything reading the tree is
112 // concerned — a chi.Walk, which is how the donors' TestEveryGetRouteHasAHeadTwin
113 // asks the question at all, and chi's own 405 handler, which builds its Allow
114 // header out of the methods that were registered. The service answers HEAD
115 // anyway, from a middleware, and the two records disagree; the request context
116 // then says GET while the request says HEAD, so which one a handler or a later
117 // middleware believes depends on which it happened to read. Registering the pair
118 // leaves one record of what is served, and it is the tree.
119 //
120 // It is also two lines against a middleware that re-enters routing on every HEAD
121 // that missed.
122 //
123 // Mutating routes deliberately do not go through it. A HEAD that writes is not a
124 // HEAD, so a form's POST is registered with r.Post next to its GET, and a HEAD
125 // on that path resolves to the page.
126 28 func GetHead(r chi.Router, pattern string, h http.HandlerFunc) {
127 28 r.Get(pattern, h)
128 28 r.Head(pattern, h)
129 28 }
130
131 // An ErrorRenderer is a service's own error page, as every donor already spells
132 // it: the status it is answering with and the sentence the viewer can act on.
133 //
134 // It is this shape and not middleware.RecoverPanics's (w, r, recovered) because
135 // the two callbacks are answering different questions. A panic has one status
136 // and one message and hands over a value to classify; a refusal has no value and
137 // two statuses, and the renderer is the donors' existing renderError method,
138 // which is passed by name rather than wrapped in a closure per call site.
139 type ErrorRenderer func(w http.ResponseWriter, r *http.Request, status int, message string)
140
141 // RenderRefusals points chi's two routing failures at the service's error page.
142 //
143 // A path the router does not serve and a method it does not allow are answered
144 // by chi with net/http's Error: text/plain, no chrome, no nav, no way out for a
145 // viewer who mistyped a URL. Five of the donors' routers install a pair of
146 // closures to fix that and spell them identically, compare installs the 404
147 // alone and lives with chi's 405, and spec and dolt install neither — so a wrong
148 // address on those two is the only refusal on the instance that does not look
149 // like the service it came from.
150 //
151 // The messages are the shared ones — pages.NotFoundMessage and
152 // pages.MethodMessage — rather than the caller's. The 404 above all is not free
153 // prose: the visibility rules of these services require "somebody else's private
154 // thing" and "no such thing" to be indistinguishable (SPEC ch. 6.3), and a
155 // router's 404 that differed in its wording from a handler's would rebuild by
156 // hand the distinction the status code was chosen to erase. A service that wants
157 // other prose has the seam anyway, in the renderer it passes.
158 //
159 // One thing is lost by taking the 405 over from chi and is worth knowing rather
160 // than discovering: chi hands the list of methods that *would* have matched only
161 // to its own default handler, through unexported types, so a custom one cannot
162 // emit the Allow header RFC 9110 §15.5.6 asks a 405 for. Every donor already
163 // made that trade, silently. It is the right way round for these services, whose
164 // 405s are answered to browsers rather than to clients negotiating a method, and
165 // it is recorded here so that a service that does need Allow knows it has to
166 // walk the tree for it.
167 //
168 // It is a function taking a router, not a middleware, because chi's NotFound and
169 // MethodNotAllowed are registrations on the routing tree and not links in a
170 // chain: they run after routing has failed, so a chain has nothing left to hand
171 // them. That also means they are inherited by every sub-router mounted later —
172 // installing them once on the root is enough.
173 //
174 // A nil renderer is a wiring mistake and panics here, at construction, rather
175 // than at the first mistyped URL.
176 6 func RenderRefusals(r chi.Router, render ErrorRenderer) {
177 6 if render == nil {
178 1 panic("chimw: RenderRefusals needs a render callback")
179 }
180 5 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
181 4 render(w, r, http.StatusNotFound, pages.NotFoundMessage)
182 4 })
183 5 r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
184 1 render(w, r, http.StatusMethodNotAllowed, pages.MethodMessage)
185 1 })
186 }