feat(api): add OpenAPI contact metadata

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 20:35:08 +00:00
parent d45ee6598e
commit a589d3bac6
9 changed files with 280 additions and 61 deletions

36
api.go
View file

@ -25,21 +25,24 @@ 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
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
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.
@ -192,6 +195,9 @@ func (e *Engine) build() *gin.Engine {
e.swaggerTitle,
e.swaggerDesc,
e.swaggerVersion,
e.swaggerContactName,
e.swaggerContactURL,
e.swaggerContactEmail,
e.swaggerServers,
e.swaggerLicenseName,
e.swaggerLicenseURL,

View file

@ -24,16 +24,19 @@ const (
func addSDKCommand(parent *cli.Command) {
var (
lang string
output string
specFile string
packageName string
title string
description string
version string
licenseName string
licenseURL string
servers string
lang string
output string
specFile string
packageName string
title string
description string
version string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL string
servers string
)
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
@ -44,7 +47,7 @@ func addSDKCommand(parent *cli.Command) {
// If no spec file provided, generate one to a temp file.
if specFile == "" {
builder := sdkSpecBuilder(title, description, version, licenseName, licenseURL, servers)
builder := sdkSpecBuilder(title, description, version, contactName, contactURL, contactEmail, licenseName, licenseURL, servers)
groups := sdkSpecGroups()
tmpFile, err := os.CreateTemp("", "openapi-*.json")
@ -93,6 +96,9 @@ 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, &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")
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, &servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
@ -100,14 +106,17 @@ func addSDKCommand(parent *cli.Command) {
parent.AddCommand(cmd)
}
func sdkSpecBuilder(title, description, version, licenseName, licenseURL, servers string) *goapi.SpecBuilder {
func sdkSpecBuilder(title, description, version, contactName, contactURL, contactEmail, licenseName, licenseURL, servers string) *goapi.SpecBuilder {
return &goapi.SpecBuilder{
Title: title,
Description: description,
Version: version,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
Title: title,
Description: description,
Version: version,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
}
}

View file

@ -13,25 +13,31 @@ import (
func addSpecCommand(parent *cli.Command) {
var (
output string
format string
title string
description string
version string
licenseName string
licenseURL string
servers string
output string
format string
title string
description string
version string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL 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,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
Title: title,
Description: description,
Version: version,
ContactName: contactName,
ContactURL: contactURL,
ContactEmail: contactEmail,
Servers: parseServers(servers),
LicenseName: licenseName,
LicenseURL: licenseURL,
}
bridge := goapi.NewToolBridge("/tools")
@ -53,6 +59,9 @@ 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, &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")
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, &servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")

View file

@ -82,6 +82,15 @@ func TestAPISpecCmd_Good_JSON(t *testing.T) {
if specCmd.Flag("version") == nil {
t.Fatal("expected --version flag on spec command")
}
if specCmd.Flag("contact-name") == nil {
t.Fatal("expected --contact-name flag on spec command")
}
if specCmd.Flag("contact-url") == nil {
t.Fatal("expected --contact-url flag on spec command")
}
if specCmd.Flag("contact-email") == nil {
t.Fatal("expected --contact-email flag on spec command")
}
if specCmd.Flag("license-name") == nil {
t.Fatal("expected --license-name flag on spec command")
}
@ -123,6 +132,54 @@ func TestAPISpecCmd_Good_CustomDescription(t *testing.T) {
}
}
func TestAPISpecCmd_Good_ContactFlagsPopulateSpecInfo(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
outputFile := t.TempDir() + "/spec.json"
root.SetArgs([]string{
"api", "spec",
"--contact-name", "API Support",
"--contact-url", "https://example.com/support",
"--contact-email", "support@example.com",
"--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")
}
contact, ok := info["contact"].(map[string]any)
if !ok {
t.Fatal("expected contact metadata in generated spec")
}
if contact["name"] != "API Support" {
t.Fatalf("expected contact name API Support, got %v", contact["name"])
}
if contact["url"] != "https://example.com/support" {
t.Fatalf("expected contact url to be preserved, got %v", contact["url"])
}
if contact["email"] != "support@example.com" {
t.Fatalf("expected contact email to be preserved, got %v", contact["email"])
}
}
func TestAPISpecCmd_Good_ServerFlagAddsServers(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
@ -304,6 +361,15 @@ func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) {
if sdkCmd.Flag("version") == nil {
t.Fatal("expected --version flag on sdk command")
}
if sdkCmd.Flag("contact-name") == nil {
t.Fatal("expected --contact-name flag on sdk command")
}
if sdkCmd.Flag("contact-url") == nil {
t.Fatal("expected --contact-url flag on sdk command")
}
if sdkCmd.Flag("contact-email") == nil {
t.Fatal("expected --contact-email flag on sdk command")
}
if sdkCmd.Flag("license-name") == nil {
t.Fatal("expected --license-name flag on sdk command")
}
@ -329,6 +395,9 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) {
"Custom SDK API",
"Custom SDK description",
"9.9.9",
"SDK Support",
"https://example.com/support",
"support@example.com",
"EUPL-1.2",
"https://eupl.eu/1.2/en/",
"https://api.example.com, /, https://api.example.com",
@ -364,6 +433,20 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) {
t.Fatalf("expected custom version, got %v", info["version"])
}
contact, ok := info["contact"].(map[string]any)
if !ok {
t.Fatal("expected contact metadata in generated spec")
}
if contact["name"] != "SDK Support" {
t.Fatalf("expected contact name SDK Support, got %v", contact["name"])
}
if contact["url"] != "https://example.com/support" {
t.Fatalf("expected contact url to be preserved, got %v", contact["url"])
}
if contact["email"] != "support@example.com" {
t.Fatalf("expected contact email to be preserved, got %v", contact["email"])
}
license, ok := info["license"].(map[string]any)
if !ok {
t.Fatal("expected licence metadata in generated spec")

View file

@ -11,7 +11,7 @@ import (
)
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
// Title, Description, Version, and optional licence metadata populate the
// Title, Description, Version, and optional contact/licence metadata populate the
// OpenAPI info block.
//
// Example:
@ -19,12 +19,15 @@ 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
Servers []string
LicenseName string
LicenseURL string
Title string
Description string
Version string
ContactName string
ContactURL string
ContactEmail string
Servers []string
LicenseName string
LicenseURL string
}
// Build generates the complete OpenAPI 3.1 JSON spec.
@ -61,6 +64,20 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
spec["info"].(map[string]any)["license"] = license
}
if sb.ContactName != "" || sb.ContactURL != "" || sb.ContactEmail != "" {
contact := map[string]any{}
if sb.ContactName != "" {
contact["name"] = sb.ContactName
}
if sb.ContactURL != "" {
contact["url"] = sb.ContactURL
}
if sb.ContactEmail != "" {
contact["email"] = sb.ContactEmail
}
spec["info"].(map[string]any)["contact"] = contact
}
if servers := normaliseServers(sb.Servers); len(servers) > 0 {
out := make([]map[string]any, 0, len(servers))
for _, server := range servers {

View file

@ -178,6 +178,42 @@ func TestSpecBuilder_Good_InfoIncludesLicenseMetadata(t *testing.T) {
}
}
func TestSpecBuilder_Good_InfoIncludesContactMetadata(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Contact test API",
Version: "1.2.3",
ContactName: "API Support",
ContactURL: "https://example.com/support",
ContactEmail: "support@example.com",
}
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)
contact, ok := info["contact"].(map[string]any)
if !ok {
t.Fatal("expected contact metadata in spec info")
}
if contact["name"] != "API Support" {
t.Fatalf("expected contact name API Support, got %v", contact["name"])
}
if contact["url"] != "https://example.com/support" {
t.Fatalf("expected contact url to be preserved, got %v", contact["url"])
}
if contact["email"] != "support@example.com" {
t.Fatalf("expected contact email to be preserved, got %v", contact["email"])
}
}
func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",

View file

@ -129,6 +129,16 @@ func WithSwagger(title, description, version string) Option {
}
}
// 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 {
return func(e *Engine) {
e.swaggerContactName = name
e.swaggerContactURL = url
e.swaggerContactEmail = email
}
}
// WithSwaggerServers adds OpenAPI server metadata to the generated Swagger spec.
// Empty strings are ignored. Multiple calls append and normalise the combined
// server list so callers can compose metadata across options.

View file

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

View file

@ -301,6 +301,52 @@ func TestSwagger_Good_UsesLicenseMetadata(t *testing.T) {
}
}
func TestSwagger_Good_UsesContactMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithSwagger("Contact API", "Contact test", "1.0.0"),
api.WithSwaggerContact("API Support", "https://example.com/support", "support@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)
}
info := doc["info"].(map[string]any)
contact, ok := info["contact"].(map[string]any)
if !ok {
t.Fatal("expected contact metadata in swagger doc")
}
if contact["name"] != "API Support" {
t.Fatalf("expected contact name=%q, got %v", "API Support", contact["name"])
}
if contact["url"] != "https://example.com/support" {
t.Fatalf("expected contact url=%q, got %v", "https://example.com/support", contact["url"])
}
if contact["email"] != "support@example.com" {
t.Fatalf("expected contact email=%q, got %v", "support@example.com", contact["email"])
}
}
func TestSwagger_Good_UsesServerMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)