From 465bd60a8a06164fe7aa8bf75273ef93df491023 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 00:42:13 +0000 Subject: [PATCH] feat: add DescribableGroup interface for OpenAPI metadata Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- group.go | 20 ++++++++ group_test.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/group.go b/group.go index 0fad863..46d0cf4 100644 --- a/group.go +++ b/group.go @@ -22,3 +22,23 @@ type StreamGroup interface { // Channels returns the list of channel names this group streams on. Channels() []string } + +// DescribableGroup extends RouteGroup with OpenAPI metadata. +// RouteGroups that implement this will have their endpoints +// included in the generated OpenAPI specification. +type DescribableGroup interface { + RouteGroup + // Describe returns endpoint descriptions for OpenAPI generation. + Describe() []RouteDescription +} + +// RouteDescription describes a single endpoint for OpenAPI generation. +type RouteDescription struct { + Method string // HTTP method: GET, POST, PUT, DELETE, PATCH + Path string // Path relative to BasePath, e.g. "/generate" + Summary string // Short summary + Description string // Long description + Tags []string // OpenAPI tags for grouping + RequestBody map[string]any // JSON Schema for request body (nil for GET) + Response map[string]any // JSON Schema for success response data +} diff --git a/group_test.go b/group_test.go index cce68b3..2eefcc0 100644 --- a/group_test.go +++ b/group_test.go @@ -88,3 +88,139 @@ func TestStreamGroup_Good_AlsoSatisfiesRouteGroup(t *testing.T) { t.Fatalf("expected Name=%q, got %q", "stub", rg.Name()) } } + +// ── DescribableGroup interface ──────────────────────────────────────── + +// describableStub implements DescribableGroup for testing. +type describableStub struct { + stubGroup + descriptions []api.RouteDescription +} + +func (d *describableStub) Describe() []api.RouteDescription { + return d.descriptions +} + +func TestDescribableGroup_Good_ImplementsRouteGroup(t *testing.T) { + stub := &describableStub{} + + // Must satisfy DescribableGroup. + var dg api.DescribableGroup = stub + if dg.Name() != "stub" { + t.Fatalf("expected Name=%q, got %q", "stub", dg.Name()) + } + + // Must also satisfy RouteGroup since DescribableGroup embeds it. + var rg api.RouteGroup = stub + if rg.BasePath() != "/stub" { + t.Fatalf("expected BasePath=%q, got %q", "/stub", rg.BasePath()) + } +} + +func TestDescribableGroup_Good_DescribeReturnsRoutes(t *testing.T) { + stub := &describableStub{ + descriptions: []api.RouteDescription{ + { + Method: "GET", + Path: "/items", + Summary: "List items", + Tags: []string{"items"}, + }, + { + Method: "POST", + Path: "/items", + Summary: "Create item", + Tags: []string{"items"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if len(descs) != 2 { + t.Fatalf("expected 2 descriptions, got %d", len(descs)) + } + if descs[0].Method != "GET" { + t.Fatalf("expected descs[0].Method=%q, got %q", "GET", descs[0].Method) + } + if descs[0].Summary != "List items" { + t.Fatalf("expected descs[0].Summary=%q, got %q", "List items", descs[0].Summary) + } + if descs[1].Method != "POST" { + t.Fatalf("expected descs[1].Method=%q, got %q", "POST", descs[1].Method) + } + if descs[1].RequestBody == nil { + t.Fatal("expected descs[1].RequestBody to be non-nil") + } +} + +func TestDescribableGroup_Good_EmptyDescribe(t *testing.T) { + stub := &describableStub{ + descriptions: nil, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if descs != nil { + t.Fatalf("expected nil descriptions, got %v", descs) + } +} + +func TestDescribableGroup_Good_MultipleVerbs(t *testing.T) { + stub := &describableStub{ + descriptions: []api.RouteDescription{ + {Method: "GET", Path: "/resources", Summary: "List resources"}, + {Method: "POST", Path: "/resources", Summary: "Create resource"}, + {Method: "DELETE", Path: "/resources/:id", Summary: "Delete resource"}, + }, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if len(descs) != 3 { + t.Fatalf("expected 3 descriptions, got %d", len(descs)) + } + + expected := []string{"GET", "POST", "DELETE"} + for i, want := range expected { + if descs[i].Method != want { + t.Fatalf("expected descs[%d].Method=%q, got %q", i, want, descs[i].Method) + } + } +} + +func TestDescribableGroup_Bad_NilSchemas(t *testing.T) { + stub := &describableStub{ + descriptions: []api.RouteDescription{ + { + Method: "GET", + Path: "/health", + Summary: "Health check", + RequestBody: nil, + Response: nil, + }, + }, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if len(descs) != 1 { + t.Fatalf("expected 1 description, got %d", len(descs)) + } + if descs[0].RequestBody != nil { + t.Fatalf("expected nil RequestBody, got %v", descs[0].RequestBody) + } + if descs[0].Response != nil { + t.Fatalf("expected nil Response, got %v", descs[0].Response) + } +}