diff --git a/api.go b/api.go index 8e3a22b..d391726 100644 --- a/api.go +++ b/api.go @@ -7,7 +7,9 @@ package api import ( "context" "errors" + "iter" "net/http" + "slices" "time" "github.com/gin-contrib/expvar" @@ -59,6 +61,11 @@ func (e *Engine) Groups() []RouteGroup { return e.groups } +// GroupsIter returns an iterator over all registered route groups. +func (e *Engine) GroupsIter() iter.Seq[RouteGroup] { + return slices.Values(e.groups) +} + // Register adds a route group to the engine. func (e *Engine) Register(group RouteGroup) { e.groups = append(e.groups, group) @@ -76,6 +83,21 @@ func (e *Engine) Channels() []string { return channels } +// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups. +func (e *Engine) ChannelsIter() iter.Seq[string] { + return func(yield func(string) bool) { + for _, g := range e.groups { + if sg, ok := g.(StreamGroup); ok { + for _, c := range sg.Channels() { + if !yield(c) { + return + } + } + } + } + } +} + // Handler builds the Gin engine and returns it as an http.Handler. // Each call produces a fresh handler reflecting the current set of groups. func (e *Engine) Handler() http.Handler { diff --git a/bridge.go b/bridge.go index 9b2a7e0..79e2e78 100644 --- a/bridge.go +++ b/bridge.go @@ -2,7 +2,11 @@ package api -import "github.com/gin-gonic/gin" +import ( + "iter" + + "github.com/gin-gonic/gin" +) // ToolDescriptor describes a tool that can be exposed as a REST endpoint. type ToolDescriptor struct { @@ -73,6 +77,30 @@ func (b *ToolBridge) Describe() []RouteDescription { return descs } +// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools. +func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] { + return func(yield func(RouteDescription) bool) { + for _, t := range b.tools { + tags := []string{t.descriptor.Group} + if t.descriptor.Group == "" { + tags = []string{b.name} + } + rd := RouteDescription{ + Method: "POST", + Path: "/" + t.descriptor.Name, + Summary: t.descriptor.Description, + Description: t.descriptor.Description, + Tags: tags, + RequestBody: t.descriptor.InputSchema, + Response: t.descriptor.OutputSchema, + } + if !yield(rd) { + return + } + } + } +} + // Tools returns all registered tool descriptors. func (b *ToolBridge) Tools() []ToolDescriptor { descs := make([]ToolDescriptor, len(b.tools)) @@ -81,3 +109,14 @@ func (b *ToolBridge) Tools() []ToolDescriptor { } return descs } + +// ToolsIter returns an iterator over all registered tool descriptors. +func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] { + return func(yield func(ToolDescriptor) bool) { + for _, t := range b.tools { + if !yield(t.descriptor) { + return + } + } + } +} diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index 58cb7ce..6dd2cba 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -65,9 +65,11 @@ func addSDKCommand(parent *cli.Command) { } // Generate for each language. - languages := strings.Split(lang, ",") - for _, l := range languages { + for l := range strings.SplitSeq(lang, ",") { l = strings.TrimSpace(l) + if l == "" { + continue + } fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l) if err := gen.Generate(context.Background(), l); err != nil { return fmt.Errorf("generate %s: %w", l, err) diff --git a/codegen.go b/codegen.go index ff31a10..c3d25fe 100644 --- a/codegen.go +++ b/codegen.go @@ -5,25 +5,27 @@ package api import ( "context" "fmt" + "iter" + "maps" "os" "os/exec" "path/filepath" - "sort" + "slices" ) // Supported SDK target languages. var supportedLanguages = map[string]string{ "go": "go", - "typescript-fetch": "typescript-fetch", - "typescript-axios": "typescript-axios", - "python": "python", - "java": "java", - "csharp": "csharp-netcore", - "ruby": "ruby", - "swift": "swift5", - "kotlin": "kotlin", - "rust": "rust", - "php": "php", + "typescript-fetch": "typescript-fetch", + "typescript-axios": "typescript-axios", + "python": "python", + "java": "java", + "csharp": "csharp-netcore", + "ruby": "ruby", + "swift": "swift5", + "kotlin": "kotlin", + "rust": "rust", + "php": "php", } // SDKGenerator wraps openapi-generator-cli for SDK generation. @@ -90,10 +92,10 @@ func (g *SDKGenerator) Available() bool { // SupportedLanguages returns the list of supported SDK target languages // in sorted order for deterministic output. func SupportedLanguages() []string { - langs := make([]string, 0, len(supportedLanguages)) - for k := range supportedLanguages { - langs = append(langs, k) - } - sort.Strings(langs) - return langs + return slices.Sorted(maps.Keys(supportedLanguages)) +} + +// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order. +func SupportedLanguagesIter() iter.Seq[string] { + return slices.Values(SupportedLanguages()) } diff --git a/go.sum b/go.sum index b1f8fdb..5ff5a09 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,9 @@ forge.lthn.ai/core/cli v0.0.1 h1:nqpc4Tv8a4H/ERei+/71DVQxkCFU8HPFJP4120qPXgk= +forge.lthn.ai/core/cli v0.0.1/go.mod h1:xa3Nqw3sUtYYJ1k+1jYul18tgs6sBevCUsGsHJI1hHA= forge.lthn.ai/core/go v0.0.1 h1:ubk4nmkA3treOUNgPS28wKd1jB6cUlEQUV7jDdGa3zM= +forge.lthn.ai/core/go v0.0.1/go.mod h1:59YsnuMaAGQUxIhX68oK2/HnhQJEPWL1iEZhDTrNCbY= forge.lthn.ai/core/go-crypt v0.0.1 h1:fmFc2SJ/VOXDRjkcYoLWfL7lI4HfPJeVS/Na6zHHcvw= +forge.lthn.ai/core/go-crypt v0.0.1/go.mod h1:/j/rUN2ZMV7x1B5BYxH3QdwkgZg0HNBw5XuyFZeyxBY= github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8= github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -108,18 +111,30 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -202,6 +217,7 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= diff --git a/modernization_test.go b/modernization_test.go new file mode 100644 index 0000000..2405717 --- /dev/null +++ b/modernization_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "slices" + "testing" + + api "forge.lthn.ai/core/go-api" +) + +func TestEngine_GroupsIter(t *testing.T) { + e, _ := api.New() + g1 := &healthGroup{} + e.Register(g1) + + var groups []api.RouteGroup + for g := range e.GroupsIter() { + groups = append(groups, g) + } + + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + if groups[0].Name() != "health-extra" { + t.Errorf("expected group name 'health-extra', got %q", groups[0].Name()) + } +} + +type streamGroupStub struct { + healthGroup + channels []string +} + +func (s *streamGroupStub) Channels() []string { return s.channels } + +func TestEngine_ChannelsIter(t *testing.T) { + e, _ := api.New() + g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}} + g2 := &streamGroupStub{channels: []string{"ch3"}} + e.Register(g1) + e.Register(g2) + + var channels []string + for ch := range e.ChannelsIter() { + channels = append(channels, ch) + } + + expected := []string{"ch1", "ch2", "ch3"} + if !slices.Equal(channels, expected) { + t.Fatalf("expected channels %v, got %v", expected, channels) + } +} + +func TestToolBridge_Iterators(t *testing.T) { + b := api.NewToolBridge("/tools") + desc := api.ToolDescriptor{Name: "test", Group: "g1"} + b.Add(desc, nil) + + // Test ToolsIter + var tools []api.ToolDescriptor + for t := range b.ToolsIter() { + tools = append(tools, t) + } + if len(tools) != 1 || tools[0].Name != "test" { + t.Errorf("ToolsIter failed, got %v", tools) + } + + // Test DescribeIter + var descs []api.RouteDescription + for d := range b.DescribeIter() { + descs = append(descs, d) + } + if len(descs) != 1 || descs[0].Path != "/test" { + t.Errorf("DescribeIter failed, got %v", descs) + } +} + +func TestCodegen_SupportedLanguagesIter(t *testing.T) { + var langs []string + for l := range api.SupportedLanguagesIter() { + langs = append(langs, l) + } + + if !slices.Contains(langs, "go") { + t.Errorf("SupportedLanguagesIter missing 'go'") + } + + // Should be sorted + if !slices.IsSorted(langs) { + t.Errorf("SupportedLanguagesIter should be sorted, got %v", langs) + } +} diff --git a/openapi_test.go b/openapi_test.go index c4c58a7..e5107e7 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -20,16 +20,16 @@ type specStubGroup struct { descs []api.RouteDescription } -func (s *specStubGroup) Name() string { return s.name } -func (s *specStubGroup) BasePath() string { return s.basePath } -func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} -func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs } +func (s *specStubGroup) Name() string { return s.name } +func (s *specStubGroup) BasePath() string { return s.basePath } +func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs } type plainStubGroup struct{} -func (plainStubGroup) Name() string { return "plain" } -func (plainStubGroup) BasePath() string { return "/plain" } -func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (plainStubGroup) Name() string { return "plain" } +func (plainStubGroup) BasePath() string { return "/plain" } +func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} // ── SpecBuilder tests ───────────────────────────────────────────────────── diff --git a/secure_test.go b/secure_test.go index efcdf75..781c017 100644 --- a/secure_test.go +++ b/secure_test.go @@ -101,9 +101,9 @@ func TestWithSecure_Good_AllHeadersPresent(t *testing.T) { // Verify all security headers are present on a regular route. checks := map[string]string{ - "X-Frame-Options": "DENY", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", } for header, want := range checks { diff --git a/sse.go b/sse.go index c3701c1..9adf7ee 100644 --- a/sse.go +++ b/sse.go @@ -143,4 +143,3 @@ func (b *SSEBroker) Drain() { } } } -