diff --git a/api.go b/api.go index 4058f4a..171274c 100644 --- a/api.go +++ b/api.go @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 65dde23..6334db4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/index.md b/docs/index.md index d913c48..9f9179e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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()` | diff --git a/options.go b/options.go index 36819b6..76f6f19 100644 --- a/options.go +++ b/options.go @@ -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 diff --git a/swagger.go b/swagger.go index 65b45c5..b69fa93 100644 --- a/swagger.go +++ b/swagger.go @@ -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, } diff --git a/swagger_test.go b/swagger_test.go index 636f89f..c49a492 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -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)