feat(openapi): add external docs metadata

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 20:47:38 +00:00
parent 4d7f3a9f99
commit bb7d88f3ce
10 changed files with 253 additions and 90 deletions

42
api.go
View file

@ -25,25 +25,27 @@ const shutdownTimeout = 10 * time.Second
// Engine is the central API server managing route groups and middleware. // Engine is the central API server managing route groups and middleware.
type Engine struct { type Engine struct {
addr string addr string
groups []RouteGroup groups []RouteGroup
middlewares []gin.HandlerFunc middlewares []gin.HandlerFunc
wsHandler http.Handler wsHandler http.Handler
sseBroker *SSEBroker sseBroker *SSEBroker
swaggerEnabled bool swaggerEnabled bool
swaggerTitle string swaggerTitle string
swaggerDesc string swaggerDesc string
swaggerVersion string swaggerVersion string
swaggerTermsOfService string swaggerTermsOfService string
swaggerServers []string swaggerServers []string
swaggerContactName string swaggerContactName string
swaggerContactURL string swaggerContactURL string
swaggerContactEmail string swaggerContactEmail string
swaggerLicenseName string swaggerLicenseName string
swaggerLicenseURL string swaggerLicenseURL string
pprofEnabled bool swaggerExternalDocsDescription string
expvarEnabled bool swaggerExternalDocsURL string
graphql *graphqlConfig pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
} }
// New creates an Engine with the given options. // New creates an Engine with the given options.
@ -203,6 +205,8 @@ func (e *Engine) build() *gin.Engine {
e.swaggerServers, e.swaggerServers,
e.swaggerLicenseName, e.swaggerLicenseName,
e.swaggerLicenseURL, e.swaggerLicenseURL,
e.swaggerExternalDocsDescription,
e.swaggerExternalDocsURL,
e.groups, e.groups,
) )
} }

View file

@ -24,20 +24,22 @@ const (
func addSDKCommand(parent *cli.Command) { func addSDKCommand(parent *cli.Command) {
var ( var (
lang string lang string
output string output string
specFile string specFile string
packageName string packageName string
title string title string
description string description string
version string version string
termsURL string termsURL string
contactName string contactName string
contactURL string contactURL string
contactEmail string contactEmail string
licenseName string licenseName string
licenseURL string licenseURL string
servers string externalDocsDescription string
externalDocsURL string
servers string
) )
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error { cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
@ -48,7 +50,7 @@ func addSDKCommand(parent *cli.Command) {
// If no spec file provided, generate one to a temp file. // If no spec file provided, generate one to a temp file.
if specFile == "" { if specFile == "" {
builder := sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, servers) builder := sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, externalDocsDescription, externalDocsURL, servers)
groups := sdkSpecGroups() groups := sdkSpecGroups()
tmpFile, err := os.CreateTemp("", "openapi-*.json") tmpFile, err := os.CreateTemp("", "openapi-*.json")
@ -103,23 +105,27 @@ func addSDKCommand(parent *cli.Command) {
cli.StringFlag(cmd, &contactEmail, "contact-email", "", "", "OpenAPI contact email in generated spec") cli.StringFlag(cmd, &contactEmail, "contact-email", "", "", "OpenAPI contact email in generated spec")
cli.StringFlag(cmd, &licenseName, "license-name", "", "", "OpenAPI licence name in generated spec") cli.StringFlag(cmd, &licenseName, "license-name", "", "", "OpenAPI licence name in generated spec")
cli.StringFlag(cmd, &licenseURL, "license-url", "", "", "OpenAPI licence URL in generated spec") cli.StringFlag(cmd, &licenseURL, "license-url", "", "", "OpenAPI licence URL in generated spec")
cli.StringFlag(cmd, &externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in generated spec")
cli.StringFlag(cmd, &externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in generated spec")
cli.StringFlag(cmd, &servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)") cli.StringFlag(cmd, &servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
parent.AddCommand(cmd) parent.AddCommand(cmd)
} }
func sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, servers string) *goapi.SpecBuilder { func sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, externalDocsDescription, externalDocsURL, servers string) *goapi.SpecBuilder {
return &goapi.SpecBuilder{ return &goapi.SpecBuilder{
Title: title, Title: title,
Description: description, Description: description,
Version: version, Version: version,
TermsOfService: termsURL, TermsOfService: termsURL,
ContactName: contactName, ContactName: contactName,
ContactURL: contactURL, ContactURL: contactURL,
ContactEmail: contactEmail, ContactEmail: contactEmail,
Servers: parseServers(servers), Servers: parseServers(servers),
LicenseName: licenseName, LicenseName: licenseName,
LicenseURL: licenseURL, LicenseURL: licenseURL,
ExternalDocsDescription: externalDocsDescription,
ExternalDocsURL: externalDocsURL,
} }
} }

View file

@ -13,33 +13,37 @@ import (
func addSpecCommand(parent *cli.Command) { func addSpecCommand(parent *cli.Command) {
var ( var (
output string output string
format string format string
title string title string
description string description string
version string version string
termsURL string termsURL string
contactName string contactName string
contactURL string contactURL string
contactEmail string contactEmail string
licenseName string licenseName string
licenseURL string licenseURL string
servers string externalDocsDescription string
externalDocsURL string
servers string
) )
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error { cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
// Build spec from all route groups registered for CLI generation. // Build spec from all route groups registered for CLI generation.
builder := &goapi.SpecBuilder{ builder := &goapi.SpecBuilder{
Title: title, Title: title,
Description: description, Description: description,
Version: version, Version: version,
TermsOfService: termsURL, TermsOfService: termsURL,
ContactName: contactName, ContactName: contactName,
ContactURL: contactURL, ContactURL: contactURL,
ContactEmail: contactEmail, ContactEmail: contactEmail,
Servers: parseServers(servers), Servers: parseServers(servers),
LicenseName: licenseName, LicenseName: licenseName,
LicenseURL: licenseURL, LicenseURL: licenseURL,
ExternalDocsDescription: externalDocsDescription,
ExternalDocsURL: externalDocsURL,
} }
bridge := goapi.NewToolBridge("/tools") bridge := goapi.NewToolBridge("/tools")
@ -67,6 +71,8 @@ func addSpecCommand(parent *cli.Command) {
cli.StringFlag(cmd, &contactEmail, "contact-email", "", "", "OpenAPI contact email in spec") cli.StringFlag(cmd, &contactEmail, "contact-email", "", "", "OpenAPI contact email in spec")
cli.StringFlag(cmd, &licenseName, "license-name", "", "", "OpenAPI licence name in spec") cli.StringFlag(cmd, &licenseName, "license-name", "", "", "OpenAPI licence name in spec")
cli.StringFlag(cmd, &licenseURL, "license-url", "", "", "OpenAPI licence URL in spec") cli.StringFlag(cmd, &licenseURL, "license-url", "", "", "OpenAPI licence URL in spec")
cli.StringFlag(cmd, &externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in spec")
cli.StringFlag(cmd, &externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in spec")
cli.StringFlag(cmd, &servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)") cli.StringFlag(cmd, &servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
parent.AddCommand(cmd) parent.AddCommand(cmd)

View file

@ -100,6 +100,12 @@ func TestAPISpecCmd_Good_JSON(t *testing.T) {
if specCmd.Flag("license-url") == nil { if specCmd.Flag("license-url") == nil {
t.Fatal("expected --license-url flag on spec command") t.Fatal("expected --license-url flag on spec command")
} }
if specCmd.Flag("external-docs-description") == nil {
t.Fatal("expected --external-docs-description flag on spec command")
}
if specCmd.Flag("external-docs-url") == nil {
t.Fatal("expected --external-docs-url flag on spec command")
}
if specCmd.Flag("server") == nil { if specCmd.Flag("server") == nil {
t.Fatal("expected --server flag on spec command") t.Fatal("expected --server flag on spec command")
} }
@ -218,6 +224,45 @@ func TestAPISpecCmd_Good_TermsOfServiceFlagPopulatesSpecInfo(t *testing.T) {
} }
} }
func TestAPISpecCmd_Good_ExternalDocsFlagsPopulateSpec(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
outputFile := t.TempDir() + "/spec.json"
root.SetArgs([]string{
"api", "spec",
"--external-docs-description", "Developer guide",
"--external-docs-url", "https://example.com/docs",
"--output", outputFile,
})
root.SetErr(new(bytes.Buffer))
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("expected spec file to be written: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("expected valid JSON spec, got error: %v", err)
}
externalDocs, ok := spec["externalDocs"].(map[string]any)
if !ok {
t.Fatal("expected externalDocs metadata in generated spec")
}
if externalDocs["description"] != "Developer guide" {
t.Fatalf("expected externalDocs description Developer guide, got %v", externalDocs["description"])
}
if externalDocs["url"] != "https://example.com/docs" {
t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"])
}
}
func TestAPISpecCmd_Good_ServerFlagAddsServers(t *testing.T) { func TestAPISpecCmd_Good_ServerFlagAddsServers(t *testing.T) {
root := &cli.Command{Use: "root"} root := &cli.Command{Use: "root"}
AddAPICommands(root) AddAPICommands(root)
@ -442,6 +487,8 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) {
"support@example.com", "support@example.com",
"EUPL-1.2", "EUPL-1.2",
"https://eupl.eu/1.2/en/", "https://eupl.eu/1.2/en/",
"",
"",
"https://api.example.com, /, https://api.example.com", "https://api.example.com, /, https://api.example.com",
) )
groups := sdkSpecGroups() groups := sdkSpecGroups()

View file

@ -30,6 +30,8 @@ type Engine struct {
swaggerTitle string swaggerTitle string
swaggerDesc string swaggerDesc string
swaggerVersion string swaggerVersion string
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
pprofEnabled bool pprofEnabled bool
expvarEnabled bool expvarEnabled bool
graphql *graphqlConfig graphql *graphqlConfig
@ -166,6 +168,7 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
| `WithSwaggerContact(name, url, email)` | OpenAPI contact metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring | | `WithSwaggerContact(name, url, email)` | OpenAPI contact metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
| `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs | | `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs |
| `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring | | `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
| `WithSwaggerExternalDocs(description, url)` | OpenAPI external documentation metadata | Populates the top-level `externalDocs` block without manual `SpecBuilder` wiring |
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication | | `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 | | `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 | | `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |

View file

@ -11,24 +11,26 @@ import (
) )
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups. // SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
// Title, Description, Version, and optional contact/licence metadata populate the // Title, Description, Version, and optional contact/licence/terms metadata populate the
// OpenAPI info block. // OpenAPI info block. Top-level external documentation metadata is also supported.
// //
// Example: // Example:
// //
// builder := &api.SpecBuilder{Title: "Service", Version: "1.0.0"} // builder := &api.SpecBuilder{Title: "Service", Version: "1.0.0"}
// spec, err := builder.Build(engine.Groups()) // spec, err := builder.Build(engine.Groups())
type SpecBuilder struct { type SpecBuilder struct {
Title string Title string
Description string Description string
Version string Version string
TermsOfService string TermsOfService string
ContactName string ContactName string
ContactURL string ContactURL string
ContactEmail string ContactEmail string
Servers []string Servers []string
LicenseName string LicenseName string
LicenseURL string LicenseURL string
ExternalDocsDescription string
ExternalDocsURL string
} }
// Build generates the complete OpenAPI 3.1 JSON spec. // Build generates the complete OpenAPI 3.1 JSON spec.
@ -91,6 +93,16 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
spec["servers"] = out spec["servers"] = out
} }
if sb.ExternalDocsURL != "" {
externalDocs := map[string]any{
"url": sb.ExternalDocsURL,
}
if sb.ExternalDocsDescription != "" {
externalDocs["description"] = sb.ExternalDocsDescription
}
spec["externalDocs"] = externalDocs
}
// Add component schemas for the response envelope. // Add component schemas for the response envelope.
spec["components"] = map[string]any{ spec["components"] = map[string]any{
"schemas": map[string]any{ "schemas": map[string]any{

View file

@ -238,6 +238,37 @@ func TestSpecBuilder_Good_InfoIncludesTermsOfService(t *testing.T) {
} }
} }
func TestSpecBuilder_Good_InfoIncludesExternalDocs(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "External docs test API",
Version: "1.2.3",
ExternalDocsDescription: "Developer guide",
ExternalDocsURL: "https://example.com/docs",
}
data, err := sb.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
externalDocs, ok := spec["externalDocs"].(map[string]any)
if !ok {
t.Fatal("expected externalDocs metadata in spec")
}
if externalDocs["description"] != "Developer guide" {
t.Fatalf("expected externalDocs description to be preserved, got %v", externalDocs["description"])
}
if externalDocs["url"] != "https://example.com/docs" {
t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"])
}
}
func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) { func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{ sb := &api.SpecBuilder{
Title: "Test", Title: "Test",

View file

@ -169,6 +169,16 @@ func WithSwaggerLicense(name, url string) Option {
} }
} }
// WithSwaggerExternalDocs adds top-level external documentation metadata to
// the generated Swagger spec.
// Empty URLs are ignored; the description is optional.
func WithSwaggerExternalDocs(description, url string) Option {
return func(e *Engine) {
e.swaggerExternalDocsDescription = description
e.swaggerExternalDocsURL = url
}
}
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/. // WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
// The standard pprof handlers (index, cmdline, profile, symbol, trace, // The standard pprof handlers (index, cmdline, profile, symbol, trace,
// allocs, block, goroutine, heap, mutex, threadcreate) are registered // allocs, block, goroutine, heap, mutex, threadcreate) are registered

View file

@ -40,19 +40,21 @@ func (s *swaggerSpec) ReadDoc() string {
} }
// registerSwagger mounts the Swagger UI and doc.json endpoint. // registerSwagger mounts the Swagger UI and doc.json endpoint.
func registerSwagger(g *gin.Engine, title, description, version, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL string, groups []RouteGroup) { func registerSwagger(g *gin.Engine, title, description, version, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL, externalDocsDescription, externalDocsURL string, groups []RouteGroup) {
spec := &swaggerSpec{ spec := &swaggerSpec{
builder: &SpecBuilder{ builder: &SpecBuilder{
Title: title, Title: title,
Description: description, Description: description,
Version: version, Version: version,
TermsOfService: termsOfService, TermsOfService: termsOfService,
ContactName: contactName, ContactName: contactName,
ContactURL: contactURL, ContactURL: contactURL,
ContactEmail: contactEmail, ContactEmail: contactEmail,
Servers: servers, Servers: servers,
LicenseName: licenseName, LicenseName: licenseName,
LicenseURL: licenseURL, LicenseURL: licenseURL,
ExternalDocsDescription: externalDocsDescription,
ExternalDocsURL: externalDocsURL,
}, },
groups: groups, groups: groups,
} }

View file

@ -383,6 +383,48 @@ func TestSwagger_Good_UsesTermsOfServiceMetadata(t *testing.T) {
} }
} }
func TestSwagger_Good_UsesExternalDocsMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithSwagger("Docs API", "Docs test", "1.0.0"),
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
)
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)
}
externalDocs, ok := doc["externalDocs"].(map[string]any)
if !ok {
t.Fatal("expected externalDocs metadata in swagger doc")
}
if externalDocs["description"] != "Developer guide" {
t.Fatalf("expected externalDocs description=%q, got %v", "Developer guide", externalDocs["description"])
}
if externalDocs["url"] != "https://example.com/docs" {
t.Fatalf("expected externalDocs url=%q, got %v", "https://example.com/docs", externalDocs["url"])
}
}
func TestSwagger_Good_UsesServerMetadata(t *testing.T) { func TestSwagger_Good_UsesServerMetadata(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)