diff --git a/api.go b/api.go index 43a3969..d415f68 100644 --- a/api.go +++ b/api.go @@ -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, ) } diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index 5f4724b..914dab2 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -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, } } diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index b86a9be..0fb4da2 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -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) diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go index 119f753..e42d23e 100644 --- a/cmd/api/cmd_test.go +++ b/cmd/api/cmd_test.go @@ -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() diff --git a/docs/architecture.md b/docs/architecture.md index f956889..8690380 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/openapi.go b/openapi.go index 9b83afb..f6b81c1 100644 --- a/openapi.go +++ b/openapi.go @@ -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{ diff --git a/openapi_test.go b/openapi_test.go index f784942..b40d8a3 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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", diff --git a/options.go b/options.go index 4571fee..591f16a 100644 --- a/options.go +++ b/options.go @@ -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 diff --git a/swagger.go b/swagger.go index 1e2c095..c41f46b 100644 --- a/swagger.go +++ b/swagger.go @@ -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, } diff --git a/swagger_test.go b/swagger_test.go index 609c60f..6623005 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -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)