diff --git a/api.go b/api.go index f8c0052..43a3969 100644 --- a/api.go +++ b/api.go @@ -25,24 +25,25 @@ 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 - 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 + pprofEnabled bool + expvarEnabled bool + graphql *graphqlConfig } // New creates an Engine with the given options. @@ -195,6 +196,7 @@ func (e *Engine) build() *gin.Engine { e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, + e.swaggerTermsOfService, e.swaggerContactName, e.swaggerContactURL, e.swaggerContactEmail, diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index 42e60b6..5f4724b 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -31,6 +31,7 @@ func addSDKCommand(parent *cli.Command) { title string description string version string + termsURL string contactName string contactURL string contactEmail string @@ -47,7 +48,7 @@ func addSDKCommand(parent *cli.Command) { // If no spec file provided, generate one to a temp file. if specFile == "" { - builder := sdkSpecBuilder(title, description, version, contactName, contactURL, contactEmail, licenseName, licenseURL, servers) + builder := sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, servers) groups := sdkSpecGroups() tmpFile, err := os.CreateTemp("", "openapi-*.json") @@ -96,6 +97,7 @@ func addSDKCommand(parent *cli.Command) { cli.StringFlag(cmd, &title, "title", "t", defaultSDKTitle, "API title in generated spec") cli.StringFlag(cmd, &description, "description", "d", defaultSDKDescription, "API description in generated spec") cli.StringFlag(cmd, &version, "version", "V", defaultSDKVersion, "API version in generated spec") + cli.StringFlag(cmd, &termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in generated spec") cli.StringFlag(cmd, &contactName, "contact-name", "", "", "OpenAPI contact name in generated spec") cli.StringFlag(cmd, &contactURL, "contact-url", "", "", "OpenAPI contact URL in generated spec") cli.StringFlag(cmd, &contactEmail, "contact-email", "", "", "OpenAPI contact email in generated spec") @@ -106,17 +108,18 @@ func addSDKCommand(parent *cli.Command) { parent.AddCommand(cmd) } -func sdkSpecBuilder(title, description, version, contactName, contactURL, contactEmail, licenseName, licenseURL, servers string) *goapi.SpecBuilder { +func sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, servers string) *goapi.SpecBuilder { return &goapi.SpecBuilder{ - Title: title, - Description: description, - Version: version, - 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, } } diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index daa65ce..b86a9be 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -18,6 +18,7 @@ func addSpecCommand(parent *cli.Command) { title string description string version string + termsURL string contactName string contactURL string contactEmail string @@ -29,15 +30,16 @@ func addSpecCommand(parent *cli.Command) { 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, - 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, } bridge := goapi.NewToolBridge("/tools") @@ -59,6 +61,7 @@ func addSpecCommand(parent *cli.Command) { cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec") cli.StringFlag(cmd, &description, "description", "d", "Lethean Core API", "API description in spec") cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec") + cli.StringFlag(cmd, &termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec") cli.StringFlag(cmd, &contactName, "contact-name", "", "", "OpenAPI contact name in spec") cli.StringFlag(cmd, &contactURL, "contact-url", "", "", "OpenAPI contact URL in spec") cli.StringFlag(cmd, &contactEmail, "contact-email", "", "", "OpenAPI contact email in spec") diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go index 3d042dc..119f753 100644 --- a/cmd/api/cmd_test.go +++ b/cmd/api/cmd_test.go @@ -82,6 +82,9 @@ func TestAPISpecCmd_Good_JSON(t *testing.T) { if specCmd.Flag("version") == nil { t.Fatal("expected --version flag on spec command") } + if specCmd.Flag("terms-of-service") == nil { + t.Fatal("expected --terms-of-service flag on spec command") + } if specCmd.Flag("contact-name") == nil { t.Fatal("expected --contact-name flag on spec command") } @@ -180,6 +183,41 @@ func TestAPISpecCmd_Good_ContactFlagsPopulateSpecInfo(t *testing.T) { } } +func TestAPISpecCmd_Good_TermsOfServiceFlagPopulatesSpecInfo(t *testing.T) { + root := &cli.Command{Use: "root"} + AddAPICommands(root) + + outputFile := t.TempDir() + "/spec.json" + root.SetArgs([]string{ + "api", "spec", + "--terms-of-service", "https://example.com/terms", + "--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) + } + + info, ok := spec["info"].(map[string]any) + if !ok { + t.Fatal("expected info object in generated spec") + } + if info["termsOfService"] != "https://example.com/terms" { + t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) + } +} + func TestAPISpecCmd_Good_ServerFlagAddsServers(t *testing.T) { root := &cli.Command{Use: "root"} AddAPICommands(root) @@ -361,6 +399,9 @@ func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) { if sdkCmd.Flag("version") == nil { t.Fatal("expected --version flag on sdk command") } + if sdkCmd.Flag("terms-of-service") == nil { + t.Fatal("expected --terms-of-service flag on sdk command") + } if sdkCmd.Flag("contact-name") == nil { t.Fatal("expected --contact-name flag on sdk command") } @@ -395,6 +436,7 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) { "Custom SDK API", "Custom SDK description", "9.9.9", + "https://example.com/terms", "SDK Support", "https://example.com/support", "support@example.com", @@ -433,6 +475,10 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) { t.Fatalf("expected custom version, got %v", info["version"]) } + if info["termsOfService"] != "https://example.com/terms" { + t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) + } + contact, ok := info["contact"].(map[string]any) if !ok { t.Fatal("expected contact metadata in generated spec") diff --git a/docs/architecture.md b/docs/architecture.md index 5bde594..f956889 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,6 +162,8 @@ 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` | +| `WithSwaggerTermsOfService(url)` | OpenAPI terms of service 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 | | `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring | | `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication | diff --git a/openapi.go b/openapi.go index 43dfd1f..9b83afb 100644 --- a/openapi.go +++ b/openapi.go @@ -19,15 +19,16 @@ import ( // builder := &api.SpecBuilder{Title: "Service", Version: "1.0.0"} // spec, err := builder.Build(engine.Groups()) type SpecBuilder struct { - Title string - Description string - Version 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 } // Build generates the complete OpenAPI 3.1 JSON spec. @@ -64,6 +65,10 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { spec["info"].(map[string]any)["license"] = license } + if sb.TermsOfService != "" { + spec["info"].(map[string]any)["termsOfService"] = sb.TermsOfService + } + if sb.ContactName != "" || sb.ContactURL != "" || sb.ContactEmail != "" { contact := map[string]any{} if sb.ContactName != "" { diff --git a/openapi_test.go b/openapi_test.go index ed5758d..f784942 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -214,6 +214,30 @@ func TestSpecBuilder_Good_InfoIncludesContactMetadata(t *testing.T) { } } +func TestSpecBuilder_Good_InfoIncludesTermsOfService(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Description: "Terms test API", + Version: "1.2.3", + TermsOfService: "https://example.com/terms", + } + + 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) + } + + info := spec["info"].(map[string]any) + if info["termsOfService"] != "https://example.com/terms" { + t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) + } +} + func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/options.go b/options.go index 8f5c5c1..4571fee 100644 --- a/options.go +++ b/options.go @@ -129,6 +129,14 @@ func WithSwagger(title, description, version string) Option { } } +// WithSwaggerTermsOfService adds the terms of service URL to the generated Swagger spec. +// Empty strings are ignored. +func WithSwaggerTermsOfService(url string) Option { + return func(e *Engine) { + e.swaggerTermsOfService = url + } +} + // WithSwaggerContact adds contact metadata to the generated Swagger spec. // Empty fields are ignored. Multiple calls replace the previous contact data. func WithSwaggerContact(name, url, email string) Option { diff --git a/swagger.go b/swagger.go index 63bdc6d..1e2c095 100644 --- a/swagger.go +++ b/swagger.go @@ -40,18 +40,19 @@ func (s *swaggerSpec) ReadDoc() string { } // registerSwagger mounts the Swagger UI and doc.json endpoint. -func registerSwagger(g *gin.Engine, title, description, version, 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 string, groups []RouteGroup) { spec := &swaggerSpec{ builder: &SpecBuilder{ - Title: title, - Description: description, - Version: version, - 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, }, groups: groups, } diff --git a/swagger_test.go b/swagger_test.go index 0ac4c0e..609c60f 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -347,6 +347,42 @@ func TestSwagger_Good_UsesContactMetadata(t *testing.T) { } } +func TestSwagger_Good_UsesTermsOfServiceMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New( + api.WithSwagger("Terms API", "Terms test", "1.0.0"), + api.WithSwaggerTermsOfService("https://example.com/terms"), + ) + 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) + } + + info := doc["info"].(map[string]any) + if info["termsOfService"] != "https://example.com/terms" { + t.Fatalf("expected termsOfService=%q, got %v", "https://example.com/terms", info["termsOfService"]) + } +} + func TestSwagger_Good_UsesServerMetadata(t *testing.T) { gin.SetMode(gin.TestMode)