From 86c2150a211b569a0ac6801ec521b1c74831ef1a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:44:49 +0000 Subject: [PATCH] feat(openapi): document SSE endpoint Co-Authored-By: Virgil --- api.go | 5 ++ openapi.go | 149 ++++++++++++++++++++++++++++++++++++++++++++++++ openapi_test.go | 64 +++++++++++++++++++++ swagger.go | 3 +- swagger_test.go | 40 +++++++++++++ 5 files changed, 260 insertions(+), 1 deletion(-) diff --git a/api.go b/api.go index 959e42e..ff4ed34 100644 --- a/api.go +++ b/api.go @@ -236,6 +236,10 @@ func (e *Engine) build() *gin.Engine { // Mount Swagger UI if enabled. if e.swaggerEnabled { + ssePath := "" + if e.sseBroker != nil { + ssePath = "/events" + } registerSwagger( r, e.swaggerTitle, @@ -247,6 +251,7 @@ func (e *Engine) build() *gin.Engine { } return e.graphql.path }(), + ssePath, e.swaggerTermsOfService, e.swaggerContactName, e.swaggerContactURL, diff --git a/openapi.go b/openapi.go index fe7210f..6dc427f 100644 --- a/openapi.go +++ b/openapi.go @@ -25,6 +25,7 @@ type SpecBuilder struct { Description string Version string GraphQLPath string + SSEPath string TermsOfService string ContactName string ContactURL string @@ -191,6 +192,11 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { paths[graphqlPath] = graphqlPathItem(graphqlPath, operationIDs) } + if ssePath := strings.TrimSpace(sb.SSEPath); ssePath != "" { + ssePath = normaliseOpenAPIPath(ssePath) + paths[ssePath] = ssePathItem(ssePath, operationIDs) + } + for _, g := range groups { for _, rd := range g.descs { fullPath := joinOpenAPIPath(g.group.BasePath(), rd.Path) @@ -584,6 +590,14 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { seen["graphql"] = true } + if ssePath := strings.TrimSpace(sb.SSEPath); ssePath != "" && !seen["events"] { + tags = append(tags, map[string]any{ + "name": "events", + "description": "Server-Sent Events endpoints", + }) + seen["events"] = true + } + for _, g := range groups { name := strings.TrimSpace(g.group.Name()) if name != "" && !seen[name] { @@ -661,6 +675,34 @@ func graphqlPathItem(path string, operationIDs map[string]int) map[string]any { } } +func ssePathItem(path string, operationIDs map[string]int) map[string]any { + return map[string]any{ + "get": map[string]any{ + "summary": "Server-Sent Events stream", + "description": "Streams published events as text/event-stream", + "tags": []string{"events"}, + "operationId": operationID("get", path, operationIDs), + "security": []any{ + map[string]any{ + "bearerAuth": []any{}, + }, + }, + "parameters": []map[string]any{ + { + "name": "channel", + "in": "query", + "required": false, + "description": "Restrict the stream to a specific channel", + "schema": map[string]any{ + "type": "string", + }, + }, + }, + "responses": sseResponses(), + }, + } +} + func graphqlRequestSchema() map[string]any { return map[string]any{ "type": "object", @@ -772,6 +814,89 @@ func graphqlResponses() map[string]any { } } +func sseResponses() map[string]any { + successHeaders := mergeHeaders( + standardResponseHeaders(), + rateLimitSuccessHeaders(), + sseResponseHeaders(), + ) + errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) + + return map[string]any{ + "200": map[string]any{ + "description": "Event stream", + "content": map[string]any{ + "text/event-stream": map[string]any{ + "schema": map[string]any{ + "type": "string", + }, + }, + }, + "headers": successHeaders, + }, + "401": map[string]any{ + "description": "Unauthorised", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "403": map[string]any{ + "description": "Forbidden", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "429": map[string]any{ + "description": "Too many requests", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()), + }, + "500": map[string]any{ + "description": "Internal server error", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + "504": map[string]any{ + "description": "Gateway timeout", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "additionalProperties": true, + }, + }, + }, + "headers": errorHeaders, + }, + } +} + // prepareRouteGroups snapshots route descriptions once per group so iterator- // backed implementations can be consumed safely by both tag and path builders. func prepareRouteGroups(groups []RouteGroup) []preparedRouteGroup { @@ -1111,6 +1236,30 @@ func cacheSuccessHeaders() map[string]any { } } +// sseResponseHeaders documents the response headers emitted by the SSE stream. +func sseResponseHeaders() map[string]any { + return map[string]any{ + "Cache-Control": map[string]any{ + "description": "Prevents intermediaries from caching the event stream", + "schema": map[string]any{ + "type": "string", + }, + }, + "Connection": map[string]any{ + "description": "Keeps the HTTP connection open for streaming", + "schema": map[string]any{ + "type": "string", + }, + }, + "X-Accel-Buffering": map[string]any{ + "description": "Disables buffering in compatible reverse proxies", + "schema": map[string]any{ + "type": "string", + }, + }, + } +} + // documentedResponseHeaders converts route-specific response header metadata // into OpenAPI header objects. func documentedResponseHeaders(headers map[string]string) map[string]any { diff --git a/openapi_test.go b/openapi_test.go index 1344279..7e75c01 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -267,6 +267,70 @@ func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) { } } +func TestSpecBuilder_Good_ServerSentEventsEndpoint(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + SSEPath: "/events", + } + + 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) + } + + tags := spec["tags"].([]any) + found := false + for _, tag := range tags { + tm := tag.(map[string]any) + if tm["name"] == "events" { + found = true + break + } + } + if !found { + t.Fatal("expected events tag in spec") + } + + paths := spec["paths"].(map[string]any) + pathItem, ok := paths["/events"].(map[string]any) + if !ok { + t.Fatal("expected /events path in spec") + } + + getOp := pathItem["get"].(map[string]any) + if getOp["operationId"] != "get_events" { + t.Fatalf("expected SSE operationId to be get_events, got %v", getOp["operationId"]) + } + + params := getOp["parameters"].([]any) + if len(params) != 1 { + t.Fatalf("expected one SSE query parameter, got %d", len(params)) + } + param := params[0].(map[string]any) + if param["name"] != "channel" || param["in"] != "query" { + t.Fatalf("expected channel query parameter, got %+v", param) + } + + responses := getOp["responses"].(map[string]any) + success := responses["200"].(map[string]any) + content := success["content"].(map[string]any) + if _, ok := content["text/event-stream"]; !ok { + t.Fatal("expected text/event-stream content type for SSE response") + } + headers := success["headers"].(map[string]any) + for _, name := range []string{"Cache-Control", "Connection", "X-Accel-Buffering"} { + if _, ok := headers[name]; !ok { + t.Fatalf("expected %s header in SSE response", name) + } + } +} + func TestSpecBuilder_Good_InfoIncludesLicenseMetadata(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/swagger.go b/swagger.go index 3cfc20e..3007c4c 100644 --- a/swagger.go +++ b/swagger.go @@ -48,12 +48,13 @@ func (s *swaggerSpec) ReadDoc() string { } // registerSwagger mounts the Swagger UI and doc.json endpoint. -func registerSwagger(g *gin.Engine, title, description, version, graphqlPath, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL, externalDocsDescription, externalDocsURL string, groups []RouteGroup) { +func registerSwagger(g *gin.Engine, title, description, version, graphqlPath, ssePath, termsOfService, contactName, contactURL, contactEmail string, servers []string, licenseName, licenseURL, externalDocsDescription, externalDocsURL string, groups []RouteGroup) { spec := newSwaggerSpec(&SpecBuilder{ Title: title, Description: description, Version: version, GraphQLPath: graphqlPath, + SSEPath: ssePath, TermsOfService: termsOfService, ContactName: contactName, ContactURL: contactURL, diff --git a/swagger_test.go b/swagger_test.go index 924716c..64593af 100644 --- a/swagger_test.go +++ b/swagger_test.go @@ -200,6 +200,46 @@ func TestSwagger_Good_WithToolBridge(t *testing.T) { } } +func TestSwagger_Good_IncludesSSEEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New(api.WithSwagger("SSE API", "SSE test", "1.0.0"), api.WithSSE(broker)) + 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) + } + + paths := doc["paths"].(map[string]any) + pathItem, ok := paths["/events"].(map[string]any) + if !ok { + t.Fatal("expected /events path in swagger doc") + } + + getOp := pathItem["get"].(map[string]any) + if getOp["operationId"] != "get_events" { + t.Fatalf("expected SSE operationId to be get_events, got %v", getOp["operationId"]) + } +} + func TestSwagger_Good_CachesSpec(t *testing.T) { spec := &swaggerSpecHelper{ title: "Cache Test",