Files
summercms/modules/surf/example_test.go
Jakub Zych 20bc1a0795 feat(19.3-02): opt-in /api-docs Swagger UI page with a pinned, SRI-checked swagger-ui-dist
- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document,
  converted once at boot to compact JSON in source key order; zero or several
  providers or a spec that does not convert fail boot
- http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json
  (summercms.apidocs, public, refusing to replace a plugin route)
- the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and
  crossorigin on all three assets, a per-request CSP nonce for its inline
  script, nosniff and no-referrer
- docs: API documentation page section, README rows, ExampleAPIDocsProvider
2026-10-15 17:59:54 +02:00

251 lines
8.0 KiB
Go

package surf_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
)
// secret signs the example's tokens. A real application reads its JWT
// secret from configuration.
const secret = "example-secret-that-is-long-enough"
// users loads the principal behind a token subject.
type users struct{}
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
return &bouncer.Principal{ID: id}, nil
}
// BlogPlugin is the acme.blog plugin; only its HTTP surface is shown here.
type BlogPlugin struct {
trusted []netip.Prefix
}
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
// Register reads http.trusted_proxies once, for the plugin's bucket keys.
func (p *BlogPlugin) Register(app *backpack.App) error {
p.trusted = surf.TrustedProxies(app.Config)
return nil
}
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// Middlewares registers the plugin's named middleware: here, a JWT guard
// that answers 401 when the request has no valid token.
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme.auth")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
// Buckets declares a named rate limit, used as throttle:blog.comments.
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
return map[string]surf.Bucket{
"blog.comments": {
Max: 1,
Decay: time.Minute,
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
},
}
}
// openAPISpec is the plugin's hand-written OpenAPI document. A plugin
// usually keeps it in a file and embeds it with //go:embed.
const openAPISpec = `openapi: 3.0.3
info:
title: Blog API
version: "1"
paths:
/api/blog/posts/{id}:
get:
summary: Show a post
`
// APIDocsSpec supplies the document behind the opt-in /api-docs page
// (surf.APIDocsProvider).
func (p *BlogPlugin) APIDocsSpec() ([]byte, error) {
return []byte(openAPISpec), nil
}
// Routes is the Go form of the plugin's routes.php.
func (p *BlogPlugin) Routes(r pact.Router) error {
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
g.Get("/posts/{id}", showPost)
g.Where("id", `[0-9]+`)
g.Get("/posts/{status}/list", listPosts)
g.WhereIn("status", "draft", "published")
// An auth group: every route inside needs a signed-in user.
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
auth.Get("/me", showMe)
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
})
})
return nil
}
func showPost(w http.ResponseWriter, r *http.Request) {
id, ok := surf.IntParam(r, "id")
if !ok {
http.NotFound(w, r)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
}
func listPosts(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"status": r.PathValue("status"), "data": wire.Slice[string](nil)})
}
func showMe(w http.ResponseWriter, r *http.Request) {
user, _ := bouncer.User(r.Context())
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": user.ID})
}
func addComment(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusCreated, map[string]any{"created": true})
}
func ExampleAssemble() {
// The application passes its config; http.body_limits is required there.
app := backpack.New(nil)
plugin := &BlogPlugin{}
if err := plugin.Register(app); err != nil { // the runtime calls Register
fmt.Println(err)
return
}
h, err := surf.Assemble(app, []party.Plugin{plugin})
if err != nil {
fmt.Println(err)
return
}
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
do := func(method, path string, auth bool) {
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
if auth {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
}
do("GET", "/api/blog/posts/7", false)
do("GET", "/api/blog/posts/seven", false)
do("GET", "/api/blog/posts/draft/list", false)
do("GET", "/api/blog/posts/deleted/list", false)
do("GET", "/api/blog/me", false)
do("GET", "/api/blog/me", true)
do("POST", "/api/blog/posts/7/comments", true)
do("POST", "/api/blog/posts/7/comments", true)
// Output:
// GET /api/blog/posts/7 200 {"id":7}
// GET /api/blog/posts/seven 404 404 page not found
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
// GET /api/blog/posts/deleted/list 404 404 page not found
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
// GET /api/blog/me 200 {"id":42}
// POST /api/blog/posts/7/comments 201 {"created":true}
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
}
func ExampleBuildRouter() {
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
for _, rt := range r.Routes() {
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
}
// Output:
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
}
func ExampleClientIP() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
trusted := surf.TrustedProxies(cfg)
// Through the load balancer at 10.0.0.5: the forwarded client is used.
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
viaProxy.RemoteAddr = "10.0.0.5:4711"
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
fmt.Println(surf.ClientIP(viaProxy, trusted))
// Straight from the internet: a forged header is ignored.
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
direct.RemoteAddr = "203.0.113.7:5000"
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
fmt.Println(surf.ClientIP(direct, trusted))
// Output:
// 198.51.100.23
// 203.0.113.7
}
func ExampleAPIDocsProvider() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("http.body_limits.default_bytes", 1048576)
_ = cfg.Set("http.body_limits.upload_bytes", 1048576)
_ = cfg.Set("http.api_docs.enabled", true)
h, err := surf.Assemble(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api-docs.json", nil))
fmt.Println(rec.Code, rec.Body.String())
// Output:
// 200 {"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/blog/posts/{id}":{"get":{"summary":"Show a post"}}}}
}
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
// limiting pages show.
func TestDocsDeclarations(t *testing.T) {
p := &BlogPlugin{}
if _, ok := p.Middlewares()["acme.auth"]; !ok {
t.Fatal("Middlewares() has no acme.auth")
}
if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute {
t.Fatalf("Buckets() = %+v", b)
}
if spec, err := p.APIDocsSpec(); err != nil || !strings.HasPrefix(string(spec), "openapi:") {
t.Fatalf("APIDocsSpec() = %q, %v", spec, err)
}
}