feat(openapi): document SSE endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
02082db8f4
commit
86c2150a21
5 changed files with 260 additions and 1 deletions
5
api.go
5
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,
|
||||
|
|
|
|||
149
openapi.go
149
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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue