Writing an adapter#
An adapter’s whole job is turning an http.Handler into whatever handler
type a framework wants. It contains no protocol logic, and it never should.
If you find yourself parsing a hub. parameter in an adapter, the fix
belongs in core.
Do you need one at all#
If your router accepts an http.Handler, no. chi, gorilla/mux, and
net/http’s own ServeMux all do:
r.Handle("/websub/*", subscriber) // chi
r.PathPrefix("/websub/").Handler(subscriber) // gorilla/mux
mux.Handle("/websub/", subscriber) // net/httpNo adapter package exists for these, and none will. Publishing empty modules would imply they were needed.
The shape#
package websubmyrouter
func Handler(h http.Handler) myrouter.HandlerFunc {
return myrouter.WrapH(h)
}That is the entire gin adapter, and the echo one differs only in the function it calls. If yours is longer, the extra length should be a translation the framework forced, with a comment saying what it is compensating for.
Mounting#
A subscriber’s callbacks live under a prefix, because each subscription gets its own unguessable path segment below the URL the subscriber was built with. Routes must match a prefix:
r.Any("/websub/*callback", websubgin.Handler(sub)) // gin
e.Any("/websub/*", websubecho.Handler(sub)) // echo
app.All("/websub/*", websubfiber.Handler(sub)) // fiberA hub serves one URL and needs no wildcard. A publisher serves no route of its own; wrap whatever already serves the topic:
r.GET("/feed", websubgin.Handler(publisher.Middleware("/feed", feed)))Proving it works#
Replay the conformance suite through your wrapper. This is the part that finds real bugs, and it is not optional for an adapter in this repo.
func config() conformance.Config {
return conformance.Config{
Wrap: func(h http.Handler) http.Handler {
r := myrouter.New()
r.Any("/*rest", websubmyrouter.Handler(h))
return r
},
}
}
func TestConformance(t *testing.T) { conformance.Run(t, config()) }
func TestPassthrough(t *testing.T) { conformance.RunPassthrough(t, config()) }If your framework is not built on net/http, use Serve instead of Wrap
and start the framework’s own server. Testing through a framework’s net/http
bridge tests the bridge, not the framework.
What frameworks get wrong#
The protocol depends on four things surviving the trip to your handler. Each has a passthrough case because each has been broken by something.
Request body, byte for byte. A signature is an HMAC over exactly the bytes the hub sent. A framework that buffers, re-encodes, or trims makes every signed delivery fail verification, and the error will look like a hub problem.
Repeated headers as separate values. WebSub sends two Link headers on
a content distribution request, one rel=hub and one rel=self. A
framework that keeps only the last one leaves a hub unable to tell what
content belongs to.
This is not hypothetical. Fiber’s net/http bridge does exactly that, which
is why adapter/fiber puts them back:
ctx, ok := r.Context().(*fasthttp.RequestCtx)
// fasthttp keeps every value; the conversion to http.Request keeps the lastQuery encoding. The verification challenge is hub-chosen text that has
to be echoed exactly. A framework that decodes it twice, or normalizes +,
breaks verification for some challenges and not others, which is the worst
kind of bug to be handed.
Status codes. 404 declines a verification request and 202 accepts a subscription. Both are protocol signals, not error handling. A framework that maps them to something friendlier breaks the handshake.
The bug the fiber adapter found#
Running the suite through fiber found a use after free in the hub, not in
fiber. Hub.background derived its context from the request with
context.WithoutCancel, which is safe on net/http and not safe on fasthttp,
which recycles request state as soon as the handler returns. Verification
running after the 202 was reading freed memory. The race detector caught it.
That is the argument for replaying real cases through real frameworks rather than writing a smoke test per adapter. The bug was in code that had a hundred and thirty passing conformance cases against it, and it took a framework with different memory behavior to expose it.
Checklist#
- New module under
adapter/<name>/with its owngo.mod. - One exported
Handlerfunction. No protocol logic. - Add the module to
go.work. CI reads its module list from there. - Run
conformance.Runandconformance.RunPassthroughthrough it, under-race. - Document the mounting pattern, including the wildcard, in the package doc comment.
- Add a row to the router table in the README.