From d803ac8f3b0dcac8508cb71c1b91f3198d3a4e63 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:19:43 +0000 Subject: [PATCH] feat(api): add engine OpenAPI spec builder Co-Authored-By: Virgil --- api.go | 29 +--------- spec_builder_helper.go | 41 ++++++++++++++ spec_builder_helper_test.go | 110 ++++++++++++++++++++++++++++++++++++ swagger.go | 21 +------ 4 files changed, 155 insertions(+), 46 deletions(-) create mode 100644 spec_builder_helper.go create mode 100644 spec_builder_helper_test.go diff --git a/api.go b/api.go index 59ddcb0..5c8d08f 100644 --- a/api.go +++ b/api.go @@ -238,34 +238,7 @@ func (e *Engine) build() *gin.Engine { // Mount Swagger UI if enabled. if e.swaggerEnabled { - ssePath := "" - if e.sseBroker != nil { - ssePath = resolveSSEPath(e.ssePath) - } - registerSwagger( - r, - resolveSwaggerPath(e.swaggerPath), - e.swaggerTitle, - e.swaggerDesc, - e.swaggerVersion, - func() string { - if e.graphql == nil { - return "" - } - return e.graphql.path - }(), - ssePath, - e.swaggerTermsOfService, - e.swaggerContactName, - e.swaggerContactURL, - e.swaggerContactEmail, - e.swaggerServers, - e.swaggerLicenseName, - e.swaggerLicenseURL, - e.swaggerExternalDocsDescription, - e.swaggerExternalDocsURL, - e.groups, - ) + registerSwagger(r, e, e.groups) } // Mount pprof profiling endpoints if enabled. diff --git a/spec_builder_helper.go b/spec_builder_helper.go new file mode 100644 index 0000000..f10c9cd --- /dev/null +++ b/spec_builder_helper.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import "slices" + +// OpenAPISpecBuilder returns a SpecBuilder populated from the engine's current +// Swagger and transport metadata. +// +// Example: +// +// builder := engine.OpenAPISpecBuilder() +func (e *Engine) OpenAPISpecBuilder() *SpecBuilder { + if e == nil { + return &SpecBuilder{} + } + + builder := &SpecBuilder{ + Title: e.swaggerTitle, + Description: e.swaggerDesc, + Version: e.swaggerVersion, + TermsOfService: e.swaggerTermsOfService, + ContactName: e.swaggerContactName, + ContactURL: e.swaggerContactURL, + ContactEmail: e.swaggerContactEmail, + Servers: slices.Clone(e.swaggerServers), + LicenseName: e.swaggerLicenseName, + LicenseURL: e.swaggerLicenseURL, + ExternalDocsDescription: e.swaggerExternalDocsDescription, + ExternalDocsURL: e.swaggerExternalDocsURL, + } + + if e.graphql != nil { + builder.GraphQLPath = e.graphql.path + } + if e.sseBroker != nil { + builder.SSEPath = resolveSSEPath(e.ssePath) + } + + return builder +} diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go new file mode 100644 index 0000000..94e4982 --- /dev/null +++ b/spec_builder_helper_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "testing" + + "github.com/gin-gonic/gin" + + api "dappco.re/go/core/api" +) + +func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New( + api.WithSwagger("Engine API", "Engine metadata", "2.0.0"), + api.WithSwaggerTermsOfService("https://example.com/terms"), + api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com"), + api.WithSwaggerServers("https://api.example.com", "/", "https://api.example.com"), + api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"), + api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"), + api.WithGraphQL(newTestSchema(), api.WithGraphQLPath("/gql")), + api.WithSSE(broker), + api.WithSSEPath("/events"), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + builder := e.OpenAPISpecBuilder() + data, err := builder.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, ok := spec["info"].(map[string]any) + if !ok { + t.Fatal("expected info object in generated spec") + } + if info["title"] != "Engine API" { + t.Fatalf("expected title Engine API, got %v", info["title"]) + } + if info["description"] != "Engine metadata" { + t.Fatalf("expected description Engine metadata, got %v", info["description"]) + } + if info["version"] != "2.0.0" { + t.Fatalf("expected version 2.0.0, got %v", info["version"]) + } + + 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"]) + } + + license, ok := info["license"].(map[string]any) + if !ok { + t.Fatal("expected licence metadata in generated spec") + } + if license["name"] != "EUPL-1.2" { + t.Fatalf("expected licence name EUPL-1.2, got %v", license["name"]) + } + + if info["termsOfService"] != "https://example.com/terms" { + t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) + } + + externalDocs, ok := spec["externalDocs"].(map[string]any) + if !ok { + t.Fatal("expected externalDocs metadata in generated spec") + } + if externalDocs["url"] != "https://example.com/docs" { + t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"]) + } + + servers, ok := spec["servers"].([]any) + if !ok { + t.Fatalf("expected servers array in generated spec, got %T", spec["servers"]) + } + if len(servers) != 2 { + t.Fatalf("expected 2 normalised servers, got %d", len(servers)) + } + if servers[0].(map[string]any)["url"] != "https://api.example.com" { + t.Fatalf("expected first server to be https://api.example.com, got %v", servers[0]) + } + if servers[1].(map[string]any)["url"] != "/" { + t.Fatalf("expected second server to be /, got %v", servers[1]) + } + + paths, ok := spec["paths"].(map[string]any) + if !ok { + t.Fatalf("expected paths object in generated spec, got %T", spec["paths"]) + } + if _, ok := paths["/gql"]; !ok { + t.Fatal("expected GraphQL path from engine metadata in generated spec") + } + if _, ok := paths["/events"]; !ok { + t.Fatal("expected SSE path from engine metadata in generated spec") + } +} diff --git a/swagger.go b/swagger.go index b398002..7ad962a 100644 --- a/swagger.go +++ b/swagger.go @@ -52,24 +52,9 @@ func (s *swaggerSpec) ReadDoc() string { } // registerSwagger mounts the Swagger UI and doc.json endpoint. -func registerSwagger(g *gin.Engine, swaggerPath, title, description, version, graphqlPath, ssePath, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL, externalDocsDescription, externalDocsURL string, groups []RouteGroup) { - swaggerPath = resolveSwaggerPath(swaggerPath) - spec := newSwaggerSpec(&SpecBuilder{ - Title: title, - Description: description, - Version: version, - GraphQLPath: graphqlPath, - SSEPath: ssePath, - TermsOfService: termsOfService, - ContactName: contactName, - ContactURL: contactURL, - ContactEmail: contactEmail, - Servers: servers, - LicenseName: licenseName, - LicenseURL: licenseURL, - ExternalDocsDescription: externalDocsDescription, - ExternalDocsURL: externalDocsURL, - }, groups) +func registerSwagger(g *gin.Engine, e *Engine, groups []RouteGroup) { + swaggerPath := resolveSwaggerPath(e.swaggerPath) + spec := newSwaggerSpec(e.OpenAPISpecBuilder(), groups) name := fmt.Sprintf("swagger_%d", swaggerSeq.Add(1)) swag.Register(name, spec) g.GET(swaggerPath+"/*any", ginSwagger.WrapHandler(swaggerFiles.NewHandler(), ginSwagger.InstanceName(name)))