feat(openapi): document SSE endpoint

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 01:44:49 +00:00
parent 02082db8f4
commit 86c2150a21
5 changed files with 260 additions and 1 deletions

5
api.go
View file

@ -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,

View file

@ -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 {

View file

@ -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",

View file

@ -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,

View file

@ -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",