feat(api): expose swagger server metadata

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 16:07:16 +00:00
parent e713fb9f56
commit 90600aa434
6 changed files with 64 additions and 3 deletions

3
api.go
View file

@ -34,6 +34,7 @@ type Engine struct {
swaggerTitle string
swaggerDesc string
swaggerVersion string
swaggerServers []string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
@ -184,7 +185,7 @@ func (e *Engine) build() *gin.Engine {
// Mount Swagger UI if enabled.
if e.swaggerEnabled {
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.swaggerServers, e.groups)
}
// Mount pprof profiling endpoints if enabled.

View file

@ -159,6 +159,7 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
| `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs |
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |

View file

@ -94,7 +94,7 @@ engine.Register(&Routes{service: svc})
| File | Purpose |
|------|---------|
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
| `options.go` | All `With*()` option functions (27 options) |
| `options.go` | All `With*()` option functions (28 options) |
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |

View file

@ -129,6 +129,15 @@ func WithSwagger(title, description, version string) Option {
}
}
// WithSwaggerServers adds OpenAPI server metadata to the generated Swagger spec.
// Empty strings are ignored. Combine it with WithSwagger() to expose the same
// server list through both the runtime Swagger UI and exported OpenAPI files.
func WithSwaggerServers(servers ...string) Option {
return func(e *Engine) {
e.swaggerServers = append([]string(nil), servers...)
}
}
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
// allocs, block, goroutine, heap, mutex, threadcreate) are registered

View file

@ -40,12 +40,13 @@ func (s *swaggerSpec) ReadDoc() string {
}
// registerSwagger mounts the Swagger UI and doc.json endpoint.
func registerSwagger(g *gin.Engine, title, description, version string, groups []RouteGroup) {
func registerSwagger(g *gin.Engine, title, description, version string, servers []string, groups []RouteGroup) {
spec := &swaggerSpec{
builder: &SpecBuilder{
Title: title,
Description: description,
Version: version,
Servers: servers,
},
groups: groups,
}

View file

@ -258,6 +258,55 @@ func TestSwagger_Good_InfoFromOptions(t *testing.T) {
}
}
func TestSwagger_Good_UsesServerMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithSwagger("Server API", "Server metadata test", "1.0.0"),
api.WithSwaggerServers("https://api.example.com", "/", ""),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/swagger/doc.json")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
servers, ok := doc["servers"].([]any)
if !ok {
t.Fatalf("expected servers array, got %T", doc["servers"])
}
if len(servers) != 2 {
t.Fatalf("expected 2 servers, got %d", len(servers))
}
first := servers[0].(map[string]any)
if first["url"] != "https://api.example.com" {
t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
}
second := servers[1].(map[string]any)
if second["url"] != "/" {
t.Fatalf("expected second server url=%q, got %v", "/", second["url"])
}
}
func TestSwagger_Good_ValidOpenAPI(t *testing.T) {
gin.SetMode(gin.TestMode)