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.
type Engine struct {
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
swaggerTermsOfService string
swaggerServers []string
swaggerContactName string
swaggerContactURL string
swaggerContactEmail string
swaggerLicenseName string
swaggerLicenseURL string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
swaggerTermsOfService string
swaggerServers []string
swaggerContactName string
swaggerContactURL string
swaggerContactEmail string
swaggerLicenseName string
swaggerLicenseURL string
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
}
// New creates an Engine with the given options.
@ -203,6 +205,8 @@ func (e *Engine) build() *gin.Engine {
e.swaggerServers,
e.swaggerLicenseName,
e.swaggerLicenseURL,
e.swaggerExternalDocsDescription,
e.swaggerExternalDocsURL,
e.groups,
)
}

View file

@ -24,20 +24,22 @@ const (
func addSDKCommand(parent *cli.Command) {
var (
lang string
output string
specFile string
packageName string
title string
description string
version string
termsURL string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL string
servers string
lang string
output string
specFile string
packageName string
title string
description string
version string
termsURL string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL string
externalDocsDescription string
externalDocsURL string
servers string
)
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 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()
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, &licenseName, "license-name", "", "", "OpenAPI licence name 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)")
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{
Title: title,
Description: description,
Version: version,
TermsOfService: termsURL,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
Title: title,
Description: description,
Version: version,
TermsOfService: termsURL,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
ExternalDocsDescription: externalDocsDescription,
ExternalDocsURL: externalDocsURL,
}
}

View file

@ -13,33 +13,37 @@ import (
func addSpecCommand(parent *cli.Command) {
var (
output string
format string
title string
description string
version string
termsURL string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL string
servers string
output string
format string
title string
description string
version string
termsURL string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL string
externalDocsDescription string
externalDocsURL string
servers string
)
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
// Build spec from all route groups registered for CLI generation.
builder := &goapi.SpecBuilder{
Title: title,
Description: description,
Version: version,
TermsOfService: termsURL,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
Title: title,
Description: description,
Version: version,
TermsOfService: termsURL,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
ExternalDocsDescription: externalDocsDescription,
ExternalDocsURL: externalDocsURL,
}
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, &licenseName, "license-name", "", "", "OpenAPI licence name 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)")
parent.AddCommand(cmd)

View file

@ -100,6 +100,12 @@ func TestAPISpecCmd_Good_JSON(t *testing.T) {
if specCmd.Flag("license-url") == nil {
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 {
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) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
@ -442,6 +487,8 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) {
"support@example.com",
"EUPL-1.2",
"https://eupl.eu/1.2/en/",
"",
"",
"https://api.example.com, /, https://api.example.com",
)
groups := sdkSpecGroups()

View file

@ -30,6 +30,8 @@ type Engine struct {
swaggerTitle string
swaggerDesc string
swaggerVersion string
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
pprofEnabled bool
expvarEnabled bool
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 |
| `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 |
| `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 |
| `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

@ -11,24 +11,26 @@ import (
)
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
// Title, Description, Version, and optional contact/licence metadata populate the
// OpenAPI info block.
// Title, Description, Version, and optional contact/licence/terms metadata populate the
// OpenAPI info block. Top-level external documentation metadata is also supported.
//
// Example:
//
// builder := &api.SpecBuilder{Title: "Service", Version: "1.0.0"}
// spec, err := builder.Build(engine.Groups())
type SpecBuilder struct {
Title string
Description string
Version string
TermsOfService string
ContactName string
ContactURL string
ContactEmail string
Servers []string
LicenseName string
LicenseURL string
Title string
Description string
Version string
TermsOfService string
ContactName string
ContactURL string
ContactEmail string
Servers []string
LicenseName string
LicenseURL string
ExternalDocsDescription string
ExternalDocsURL string
}
// Build generates the complete OpenAPI 3.1 JSON spec.
@ -91,6 +93,16 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
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.
spec["components"] = 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) {
sb := &api.SpecBuilder{
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/.
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
// 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.
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{
builder: &SpecBuilder{
Title: title,
Description: description,
Version: version,
TermsOfService: termsOfService,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: servers,
LicenseName: licenseName,
LicenseURL: licenseURL,
Title: title,
Description: description,
Version: version,
TermsOfService: termsOfService,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: servers,
LicenseName: licenseName,
LicenseURL: licenseURL,
ExternalDocsDescription: externalDocsDescription,
ExternalDocsURL: externalDocsURL,
},
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) {
gin.SetMode(gin.TestMode)