feat: add OpenAPI/Swagger spec module for auto-generating HTTP routes#134
feat: add OpenAPI/Swagger spec module for auto-generating HTTP routes#134
Conversation
…#79) - Add openapi module type that parses OpenAPI v3 YAML/JSON specs - Generate HTTP route handlers from spec paths with method mapping - Add request validation against spec schemas (query params, body) - Add optional Swagger UI and spec serving endpoints - Add OpenAPI plugin for plugin-based registration - Add comprehensive tests for spec parsing, routing, and validation - Add example config and petstore spec in example/specs/ Closes #79 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive OpenAPI v3 specification support to the workflow engine, enabling automatic HTTP route generation, request validation, and optional Swagger UI hosting from OpenAPI spec files. The implementation introduces a new openapi module type via a dedicated plugin, allowing users to define API contracts declaratively and have the engine automatically wire up routes with validation.
Changes:
- New OpenAPI plugin with module factory, schema definitions, and wiring hooks for route registration
- Core OpenAPI module implementation with spec parsing (YAML/JSON), route generation, and request validation
- Comprehensive test suite covering spec parsing, route registration, validation scenarios, and edge cases
- Example petstore configuration and OpenAPI spec demonstrating the feature
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 21 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/openapi/plugin.go | Plugin registration providing the "openapi" module type, schema definitions, and wiring hooks |
| module/openapi.go | Core OpenAPI module with spec parsing, HTTP route generation, request validation, and Swagger UI |
| module/openapi_test.go | Comprehensive test coverage for spec parsing, route registration, validation, and error handling |
| example/specs/petstore.yaml | Sample OpenAPI v3 spec file demonstrating supported features |
| example/openapi-petstore.yaml | Example workflow configuration showing how to use the openapi module |
| package module | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // ---- spec fixtures ---- | ||
|
|
||
| const petstoreYAML = ` | ||
| openapi: "3.0.0" | ||
| info: | ||
| title: Petstore | ||
| version: "1.0.0" | ||
| paths: | ||
| /pets: | ||
| get: | ||
| operationId: listPets | ||
| summary: List all pets | ||
| parameters: | ||
| - name: limit | ||
| in: query | ||
| required: false | ||
| schema: | ||
| type: integer | ||
| minimum: 1 | ||
| maximum: 100 | ||
| responses: | ||
| "200": | ||
| description: A list of pets | ||
| post: | ||
| operationId: createPet | ||
| summary: Create a pet | ||
| requestBody: | ||
| required: true | ||
| content: | ||
| application/json: | ||
| schema: | ||
| type: object | ||
| required: | ||
| - name | ||
| properties: | ||
| name: | ||
| type: string | ||
| minLength: 1 | ||
| tag: | ||
| type: string | ||
| responses: | ||
| "201": | ||
| description: Created | ||
| /pets/{petId}: | ||
| get: | ||
| operationId: showPetById | ||
| summary: Get a pet by ID | ||
| parameters: | ||
| - name: petId | ||
| in: path | ||
| required: true | ||
| schema: | ||
| type: integer | ||
| responses: | ||
| "200": | ||
| description: Expected response to a valid request | ||
| ` | ||
|
|
||
| const petstoreJSON = `{ | ||
| "openapi": "3.0.0", | ||
| "info": {"title": "Petstore JSON", "version": "1.0.0"}, | ||
| "paths": { | ||
| "/items": { | ||
| "get": { | ||
| "operationId": "listItems", | ||
| "summary": "List items", | ||
| "responses": {"200": {"description": "ok"}} | ||
| } | ||
| } | ||
| } | ||
| }` | ||
|
|
||
| // ---- helpers ---- | ||
|
|
||
| // writeTempSpec writes content to a temp file and returns the path. | ||
| func writeTempSpec(t *testing.T, ext, content string) string { | ||
| t.Helper() | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "spec"+ext) | ||
| if err := os.WriteFile(path, []byte(content), 0600); err != nil { | ||
| t.Fatalf("write temp spec: %v", err) | ||
| } | ||
| return path | ||
| } | ||
|
|
||
| // newTestRouter is a minimal HTTPRouter that records registered routes. | ||
| type testRouter struct { | ||
| routes []struct { | ||
| method, path string | ||
| handler HTTPHandler | ||
| } | ||
| } | ||
|
|
||
| func (r *testRouter) AddRoute(method, path string, handler HTTPHandler) { | ||
| r.routes = append(r.routes, struct { | ||
| method, path string | ||
| handler HTTPHandler | ||
| }{method, path, handler}) | ||
| } | ||
|
|
||
| func (r *testRouter) findHandler(method, path string) HTTPHandler { | ||
| for _, rt := range r.routes { | ||
| if rt.method == method && rt.path == path { | ||
| return rt.handler | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // ---- tests ---- | ||
|
|
||
| func TestOpenAPIModule_ParseYAML(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| }) | ||
|
|
||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init failed: %v", err) | ||
| } | ||
|
|
||
| if mod.spec == nil { | ||
| t.Fatal("spec was not parsed") | ||
| } | ||
| if mod.spec.Info.Title != "Petstore" { | ||
| t.Errorf("expected title 'Petstore', got %q", mod.spec.Info.Title) | ||
| } | ||
| if len(mod.spec.Paths) != 2 { | ||
| t.Errorf("expected 2 paths, got %d", len(mod.spec.Paths)) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_ParseJSON(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".json", petstoreJSON) | ||
|
|
||
| mod := NewOpenAPIModule("json-api", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api", | ||
| }) | ||
|
|
||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init failed: %v", err) | ||
| } | ||
|
|
||
| if mod.spec.Info.Title != "Petstore JSON" { | ||
| t.Errorf("expected title 'Petstore JSON', got %q", mod.spec.Info.Title) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_MissingSpecFile(t *testing.T) { | ||
| mod := NewOpenAPIModule("bad", OpenAPIConfig{}) | ||
| if err := mod.Init(nil); err == nil { | ||
| t.Fatal("expected error for missing spec_file") | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_NonExistentFile(t *testing.T) { | ||
| mod := NewOpenAPIModule("bad", OpenAPIConfig{SpecFile: "/does/not/exist.yaml"}) | ||
| if err := mod.Init(nil); err == nil { | ||
| t.Fatal("expected error for non-existent spec file") | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_RegisterRoutes(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| // Expect: GET /api/v1/pets, POST /api/v1/pets, GET /api/v1/pets/{petId} | ||
| // plus /api/v1/openapi.json, /api/v1/openapi.yaml | ||
| paths := make(map[string]bool) | ||
| for _, rt := range router.routes { | ||
| paths[rt.method+":"+rt.path] = true | ||
| } | ||
|
|
||
| expected := []string{ | ||
| "GET:/api/v1/pets", | ||
| "POST:/api/v1/pets", | ||
| "GET:/api/v1/pets/{petId}", | ||
| "GET:/api/v1/openapi.json", | ||
| "GET:/api/v1/openapi.yaml", | ||
| } | ||
| for _, e := range expected { | ||
| if !paths[e] { | ||
| t.Errorf("expected route %q to be registered, got routes: %v", e, routeKeys(router)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_SwaggerUIRoute(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| SwaggerUI: OpenAPISwaggerUIConfig{ | ||
| Enabled: true, | ||
| Path: "/docs", | ||
| }, | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| found := false | ||
| for _, rt := range router.routes { | ||
| if rt.method == "GET" && rt.path == "/api/v1/docs" { | ||
| found = true | ||
| } | ||
| } | ||
| if !found { | ||
| t.Error("Swagger UI route /api/v1/docs not registered") | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_SwaggerUIResponse(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| SwaggerUI: OpenAPISwaggerUIConfig{ | ||
| Enabled: true, | ||
| Path: "/docs", | ||
| }, | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| h := router.findHandler("GET", "/api/v1/docs") | ||
| if h == nil { | ||
| t.Fatal("swagger UI handler not found") | ||
| } | ||
|
|
||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodGet, "/api/v1/docs", nil) | ||
| h.Handle(w, r) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Errorf("expected 200, got %d", w.Code) | ||
| } | ||
| if !strings.Contains(w.Body.String(), "swagger-ui") { | ||
| t.Error("swagger UI HTML not in response body") | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_SpecEndpoint(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| h := router.findHandler("GET", "/api/v1/openapi.json") | ||
| if h == nil { | ||
| t.Fatal("spec handler not found") | ||
| } | ||
|
|
||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil) | ||
| h.Handle(w, r) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Errorf("expected 200, got %d", w.Code) | ||
| } | ||
| var got map[string]any | ||
| if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { | ||
| t.Errorf("spec endpoint did not return valid JSON: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_RequestValidation_ValidQuery(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| Validation: OpenAPIValidationConfig{Request: true}, | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| h := router.findHandler("GET", "/api/v1/pets") | ||
| if h == nil { | ||
| t.Fatal("GET /api/v1/pets handler not found") | ||
| } | ||
|
|
||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodGet, "/api/v1/pets?limit=10", nil) | ||
| h.Handle(w, r) | ||
|
|
||
| // 501 is the stub response — validation passed | ||
| if w.Code != http.StatusNotImplemented { | ||
| t.Errorf("expected 501 stub response (validation OK), got %d: %s", w.Code, w.Body.String()) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_RequestValidation_InvalidQuery(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| Validation: OpenAPIValidationConfig{Request: true}, | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| h := router.findHandler("GET", "/api/v1/pets") | ||
| if h == nil { | ||
| t.Fatal("GET /api/v1/pets handler not found") | ||
| } | ||
|
|
||
| // "limit" must be integer — send a non-integer | ||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodGet, "/api/v1/pets?limit=notanumber", nil) | ||
| h.Handle(w, r) | ||
|
|
||
| if w.Code != http.StatusBadRequest { | ||
| t.Errorf("expected 400 validation error, got %d: %s", w.Code, w.Body.String()) | ||
| } | ||
| var errBody map[string]any | ||
| if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil { | ||
| t.Fatalf("could not decode error body: %v", err) | ||
| } | ||
| if errBody["error"] != "request validation failed" { | ||
| t.Errorf("unexpected error body: %v", errBody) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_RequestValidation_Body(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| Validation: OpenAPIValidationConfig{Request: true}, | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| h := router.findHandler("POST", "/api/v1/pets") | ||
| if h == nil { | ||
| t.Fatal("POST /api/v1/pets handler not found") | ||
| } | ||
|
|
||
| t.Run("valid body", func(t *testing.T) { | ||
| body := `{"name": "Fluffy", "tag": "cat"}` | ||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodPost, "/api/v1/pets", bytes.NewBufferString(body)) | ||
| r.Header.Set("Content-Type", "application/json") | ||
| h.Handle(w, r) | ||
| if w.Code != http.StatusNotImplemented { | ||
| t.Errorf("expected 501 stub (validation OK), got %d: %s", w.Code, w.Body.String()) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("missing required field", func(t *testing.T) { | ||
| body := `{"tag": "cat"}` // missing 'name' | ||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodPost, "/api/v1/pets", bytes.NewBufferString(body)) | ||
| r.Header.Set("Content-Type", "application/json") | ||
| h.Handle(w, r) | ||
| if w.Code != http.StatusBadRequest { | ||
| t.Errorf("expected 400 validation error, got %d: %s", w.Code, w.Body.String()) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestOpenAPIModule_NoValidation(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
|
|
||
| // Validation disabled — bad input still returns 501 (stub) | ||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{ | ||
| SpecFile: specPath, | ||
| BasePath: "/api/v1", | ||
| Validation: OpenAPIValidationConfig{Request: false}, | ||
| }) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
|
|
||
| router := &testRouter{} | ||
| mod.RegisterRoutes(router) | ||
|
|
||
| h := router.findHandler("GET", "/api/v1/pets") | ||
| if h == nil { | ||
| t.Fatal("handler not found") | ||
| } | ||
|
|
||
| w := httptest.NewRecorder() | ||
| r := httptest.NewRequest(http.MethodGet, "/api/v1/pets?limit=notanumber", nil) | ||
| h.Handle(w, r) | ||
|
|
||
| if w.Code != http.StatusNotImplemented { | ||
| t.Errorf("expected 501 (validation disabled), got %d", w.Code) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_ModuleInterface(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{SpecFile: specPath}) | ||
|
|
||
| if mod.Name() != "petstore" { | ||
| t.Errorf("Name() = %q, want %q", mod.Name(), "petstore") | ||
| } | ||
| if deps := mod.Dependencies(); deps != nil { | ||
| t.Errorf("Dependencies() should be nil") | ||
| } | ||
| providers := mod.ProvidesServices() | ||
| if len(providers) != 1 { | ||
| t.Errorf("ProvidesServices() count = %d, want 1", len(providers)) | ||
| } | ||
| if providers[0].Name != "petstore" { | ||
| t.Errorf("ProvidesServices()[0].Name = %q, want %q", providers[0].Name, "petstore") | ||
| } | ||
| if reqs := mod.RequiresServices(); reqs != nil { | ||
| t.Errorf("RequiresServices() should be nil") | ||
| } | ||
| } | ||
|
|
||
| func TestOpenAPIModule_StartStop(t *testing.T) { | ||
| specPath := writeTempSpec(t, ".yaml", petstoreYAML) | ||
| mod := NewOpenAPIModule("petstore", OpenAPIConfig{SpecFile: specPath}) | ||
| if err := mod.Init(nil); err != nil { | ||
| t.Fatalf("Init: %v", err) | ||
| } | ||
| if err := mod.Start(context.TODO()); err != nil { | ||
| t.Errorf("Start: %v", err) | ||
| } | ||
| if err := mod.Stop(context.TODO()); err != nil { | ||
| t.Errorf("Stop: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestParseOpenAPISpec_InvalidYAML(t *testing.T) { | ||
| _, err := parseOpenAPISpec([]byte(":\tinvalid: yaml: [")) | ||
| if err == nil { | ||
| t.Error("expected error for invalid YAML") | ||
| } | ||
| } | ||
|
|
||
| func TestValidateScalarValue(t *testing.T) { | ||
| minVal := 1.0 | ||
| maxVal := 100.0 | ||
| minLen := 2 | ||
| maxLen := 5 | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| val string | ||
| schema *openAPISchema | ||
| wantErr bool | ||
| }{ | ||
| {"valid integer", "42", &openAPISchema{Type: "integer", Minimum: &minVal, Maximum: &maxVal}, false}, | ||
| {"too small integer", "0", &openAPISchema{Type: "integer", Minimum: &minVal}, true}, | ||
| {"invalid integer", "abc", &openAPISchema{Type: "integer"}, true}, | ||
| {"valid number", "3.14", &openAPISchema{Type: "number"}, false}, | ||
| {"invalid number", "pi", &openAPISchema{Type: "number"}, true}, | ||
| {"valid boolean true", "true", &openAPISchema{Type: "boolean"}, false}, | ||
| {"valid boolean false", "false", &openAPISchema{Type: "boolean"}, false}, | ||
| {"invalid boolean", "yes", &openAPISchema{Type: "boolean"}, true}, | ||
| {"valid string", "hello", &openAPISchema{Type: "string", MinLength: &minLen, MaxLength: &maxLen}, false}, | ||
| {"string too short", "a", &openAPISchema{Type: "string", MinLength: &minLen}, true}, | ||
| {"string too long", "toolongstring", &openAPISchema{Type: "string", MaxLength: &maxLen}, true}, | ||
| {"enum match", "cat", &openAPISchema{Type: "string", Enum: []any{"cat", "dog"}}, false}, | ||
| {"enum mismatch", "fish", &openAPISchema{Type: "string", Enum: []any{"cat", "dog"}}, true}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| errs := validateScalarValue(tt.val, "param", tt.schema) | ||
| if tt.wantErr && len(errs) == 0 { | ||
| t.Error("expected validation error, got none") | ||
| } | ||
| if !tt.wantErr && len(errs) > 0 { | ||
| t.Errorf("unexpected validation errors: %v", errs) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestSwaggerUIHTML(t *testing.T) { | ||
| html := swaggerUIHTML("My API", "/api/v1/openapi.json") | ||
| if !strings.Contains(html, "swagger-ui") { | ||
| t.Error("HTML missing swagger-ui reference") | ||
| } | ||
| if !strings.Contains(html, "/api/v1/openapi.json") { | ||
| t.Error("HTML missing spec URL") | ||
| } | ||
| if !strings.Contains(html, "My API") { | ||
| t.Error("HTML missing title") | ||
| } | ||
| } | ||
|
|
||
| func TestHTMLEscape(t *testing.T) { | ||
| got := htmlEscape(`<script>alert("xss")</script>`) | ||
| if strings.Contains(got, "<script>") { | ||
| t.Errorf("HTML not escaped: %s", got) | ||
| } | ||
| } | ||
|
|
||
| // routeKeys returns a list of "METHOD:path" strings from a testRouter. | ||
| func routeKeys(r *testRouter) []string { | ||
| keys := make([]string, len(r.routes)) | ||
| for i, rt := range r.routes { | ||
| keys[i] = rt.method + ":" + rt.path | ||
| } | ||
| return keys | ||
| } |
There was a problem hiding this comment.
There are no tests covering path parameter validation. The spec includes path parameters (e.g., petId in /pets/{petId}), but no tests verify that required path parameters are validated or that invalid path parameter types are rejected. This is important because path parameter extraction relies on Go 1.22's PathValue, and validation gaps here could allow invalid data into route handlers.
| // htmlEscape escapes a string for safe embedding in HTML attributes/text. | ||
| func htmlEscape(s string) string { | ||
| s = strings.ReplaceAll(s, "&", "&") | ||
| s = strings.ReplaceAll(s, "<", "<") | ||
| s = strings.ReplaceAll(s, ">", ">") | ||
| s = strings.ReplaceAll(s, `"`, """) | ||
| s = strings.ReplaceAll(s, "'", "'") | ||
| return s | ||
| } |
There was a problem hiding this comment.
The htmlEscape function provides basic HTML escaping but doesn't use Go's standard html.EscapeString from the html package, which is more thoroughly tested and maintained. While the current implementation covers the main XSS vectors, using the standard library function would be more robust and follow Go best practices. Consider replacing this custom implementation with html.EscapeString.
| type: openapi | ||
| dependsOn: | ||
| - petstore-router | ||
| config: |
There was a problem hiding this comment.
The example configuration uses a relative path "example/specs/petstore.yaml" for the spec_file on line 25. This path is relative to where the engine binary is executed from, which could cause confusion if the config is run from a different directory. Consider either using an absolute path, or documenting in a comment that paths are resolved relative to the config file's directory (via the _config_dir mechanism in config.ResolvePathInConfig).
| config: | |
| config: | |
| # NOTE: spec_file is resolved relative to this config file's directory | |
| # via the _config_dir mechanism in config.ResolvePathInConfig. |
| // validateJSONValue validates a decoded JSON value against a schema. | ||
| func validateJSONValue(val any, name string, schema *openAPISchema) []string { | ||
| var errs []string | ||
| switch schema.Type { | ||
| case "string": | ||
| s, ok := val.(string) | ||
| if !ok { | ||
| return []string{fmt.Sprintf("field %q must be a string", name)} | ||
| } | ||
| if schema.MinLength != nil && len(s) < *schema.MinLength { | ||
| errs = append(errs, fmt.Sprintf("field %q must have minLength %d", name, *schema.MinLength)) | ||
| } | ||
| if schema.MaxLength != nil && len(s) > *schema.MaxLength { | ||
| errs = append(errs, fmt.Sprintf("field %q must have maxLength %d", name, *schema.MaxLength)) | ||
| } | ||
| if schema.Pattern != "" { | ||
| if ok2, _ := regexp.MatchString(schema.Pattern, s); !ok2 { | ||
| errs = append(errs, fmt.Sprintf("field %q does not match pattern %q", name, schema.Pattern)) | ||
| } | ||
| } |
There was a problem hiding this comment.
The validation logic for scalar values (validateScalarValue) and JSON values (validateJSONValue) has duplicated code for handling string validation (minLength, maxLength, pattern checks appear in both functions at lines 481-491 and 542-557). This duplication makes maintenance harder and increases the risk of inconsistencies. Consider extracting the common string validation logic into a separate helper function that both validation functions can call.
| // validateJSONValue validates a decoded JSON value against a schema. | |
| func validateJSONValue(val any, name string, schema *openAPISchema) []string { | |
| var errs []string | |
| switch schema.Type { | |
| case "string": | |
| s, ok := val.(string) | |
| if !ok { | |
| return []string{fmt.Sprintf("field %q must be a string", name)} | |
| } | |
| if schema.MinLength != nil && len(s) < *schema.MinLength { | |
| errs = append(errs, fmt.Sprintf("field %q must have minLength %d", name, *schema.MinLength)) | |
| } | |
| if schema.MaxLength != nil && len(s) > *schema.MaxLength { | |
| errs = append(errs, fmt.Sprintf("field %q must have maxLength %d", name, *schema.MaxLength)) | |
| } | |
| if schema.Pattern != "" { | |
| if ok2, _ := regexp.MatchString(schema.Pattern, s); !ok2 { | |
| errs = append(errs, fmt.Sprintf("field %q does not match pattern %q", name, schema.Pattern)) | |
| } | |
| } | |
| // validateStringField validates a value as a string against string-related schema constraints. | |
| // It returns any validation errors and a boolean indicating whether the value passed type validation. | |
| func validateStringField(val any, name string, schema *openAPISchema) ([]string, bool) { | |
| s, ok := val.(string) | |
| if !ok { | |
| return []string{fmt.Sprintf("field %q must be a string", name)}, false | |
| } | |
| var errs []string | |
| if schema.MinLength != nil && len(s) < *schema.MinLength { | |
| errs = append(errs, fmt.Sprintf("field %q must have minLength %d", name, *schema.MinLength)) | |
| } | |
| if schema.MaxLength != nil && len(s) > *schema.MaxLength { | |
| errs = append(errs, fmt.Sprintf("field %q must have maxLength %d", name, *schema.MaxLength)) | |
| } | |
| if schema.Pattern != "" { | |
| if ok2, _ := regexp.MatchString(schema.Pattern, s); !ok2 { | |
| errs = append(errs, fmt.Sprintf("field %q does not match pattern %q", name, schema.Pattern)) | |
| } | |
| } | |
| return errs, true | |
| } | |
| // validateJSONValue validates a decoded JSON value against a schema. | |
| func validateJSONValue(val any, name string, schema *openAPISchema) []string { | |
| var errs []string | |
| switch schema.Type { | |
| case "string": | |
| stringErrs, ok := validateStringField(val, name, schema) | |
| if !ok { | |
| return stringErrs | |
| } | |
| errs = append(errs, stringErrs...) |
…solution - Add "openapi" to known module types and module schema registry - Fix spec_file path in example config (relative to config dir, not project root) - Add openapi plugin to test helpers allPlugins() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
…rformance improvements (#146) * Initial plan * fix: apply all review feedback to OpenAPI module Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
| if m.cfg.SwaggerUI.Enabled { | ||
| uiPath := m.cfg.SwaggerUI.Path | ||
| if uiPath == "" { | ||
| uiPath = "/docs" |
There was a problem hiding this comment.
swagger_ui.path is concatenated directly with base_path. If swagger_ui.path is configured without a leading slash (e.g. "docs"), the resulting route becomes invalid ("/api/v1docs"). Normalize swagger_ui.path to ensure it starts with "/" before building uiRoutePath.
| uiPath = "/docs" | |
| uiPath = "/docs" | |
| } else if !strings.HasPrefix(uiPath, "/") { | |
| uiPath = "/" + uiPath |
| baseURL := os.Getenv("SWAGGER_UI_ASSETS_BASE_URL") | ||
| if baseURL == "" { | ||
| baseURL = "https://unpkg.com/swagger-ui-dist@5" | ||
| } |
There was a problem hiding this comment.
swaggerUIHTML defaults to loading assets from an unpinned CDN URL (swagger-ui-dist@5). Using a major-only tag is non-deterministic and increases supply-chain risk because contents can change over time. Prefer pinning to an exact version and/or serving local assets by default (with CDN as an opt-in).
| return map[string]plugin.ModuleFactory{ | ||
| "openapi": func(name string, cfg map[string]any) modular.Module { | ||
| oacfg := module.OpenAPIConfig{} | ||
|
|
There was a problem hiding this comment.
OpenAPIConfig's zero values mean request validation defaults to false unless explicitly set in YAML, but both the module schemas in this PR indicate validation.request should default to true. Consider setting oacfg.Validation.Request=true (and any other intended defaults like base_path) before applying cfg overrides, so runtime behavior matches the documented defaults.
| // Set documented defaults before applying configuration overrides. | |
| // validation.request is expected to default to true if not explicitly set in YAML. | |
| oacfg.Validation.Request = true | |
| // base_path may also have a documented default; use "/" unless overridden. | |
| oacfg.BasePath = "/" |
| if router, ok := svc.(module.HTTPRouter); ok { | ||
| routers[svcName] = router | ||
| if firstRouter == nil { | ||
| firstRouter = router | ||
| } |
There was a problem hiding this comment.
wireOpenAPIRoutes selects the fallback router by taking the first HTTPRouter encountered while iterating app.SvcRegistry(). Since map iteration order is randomized, with multiple routers this can non-deterministically attach generated routes to different routers across runs. Prefer a deterministic fallback (e.g., only when exactly one router exists, or based on config order) and log/return an error when ambiguous.
| var mediaType *openAPIMediaType | ||
| if mt, ok := h.op.RequestBody.Content[ct]; ok { | ||
| mediaType = &mt | ||
| } else if mt, ok := h.op.RequestBody.Content["application/json"]; ok && ct == "" { | ||
| // NOTE: Intentionally treat a missing Content-Type as application/json for request body |
There was a problem hiding this comment.
If request validation is enabled and the body is present, but Content-Type isn't listed under requestBody.content, mediaType stays nil and the body isn't validated (and no error is returned). Consider treating an unknown/unsupported Content-Type as a validation failure (400), especially when requestBody is required.
… module (#149) * Initial plan * fix(openapi): document deferred spec_file validation and add enum scalar tests Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Add admin/admin_test.go from main with the syntax error fixed: TestMergeInto_WithRealAdminConfig was closed with `)` instead of `}` and used 2-space indented brace in the inner if block, causing: expected statement, found ')' This file doesn't exist on this branch (predates its addition to main) but is needed so the PR's merge commit compiles and tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| Inputs: []schema.ServiceIODef{{Name: "request", Type: "http.Request", Description: "Incoming HTTP requests matched against spec paths"}}, | ||
| Outputs: []schema.ServiceIODef{{Name: "response", Type: "http.Response", Description: "Validated HTTP response or 501 stub"}}, |
There was a problem hiding this comment.
The plugin’s ModuleSchema for type "openapi" describes inputs/outputs as http.Request→http.Response, but the actual module wires itself by registering routes onto an http.router via a wiring hook. This schema will mislead tooling/UI that relies on module I/O definitions; align it with the built-in schema (router input / routes output) or with how the module is actually intended to be composed.
| Inputs: []schema.ServiceIODef{{Name: "request", Type: "http.Request", Description: "Incoming HTTP requests matched against spec paths"}}, | |
| Outputs: []schema.ServiceIODef{{Name: "response", Type: "http.Response", Description: "Validated HTTP response or 501 stub"}}, | |
| Inputs: []schema.ServiceIODef{{Name: "router", Type: "http.router", Description: "HTTP router to attach generated routes to"}}, | |
| Outputs: []schema.ServiceIODef{{Name: "routes", Type: "http.routes", Description: "Generated HTTP routes from the OpenAPI specification"}}, |
| if w.Code != http.StatusBadRequest { | ||
| t.Errorf("expected 400 validation error, got %d: %s", w.Code, w.Body.String()) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Request-body validation is exercised for missing required fields, but there’s no test ensuring malformed JSON (invalid syntax) fails validation when Validation.Request is enabled. Adding a test case for invalid JSON would prevent regressions once JSON decode errors are handled as validation errors.
| }) | |
| }) | |
| t.Run("invalid JSON", func(t *testing.T) { | |
| body := `{"name": "Fluffy",` // malformed JSON | |
| w := httptest.NewRecorder() | |
| r := httptest.NewRequest(http.MethodPost, "/api/v1/pets", bytes.NewBufferString(body)) | |
| r.Header.Set("Content-Type", "application/json") | |
| h.Handle(w, r) | |
| if w.Code != http.StatusBadRequest { | |
| t.Errorf("expected 400 validation error for malformed JSON, got %d: %s", w.Code, w.Body.String()) | |
| } | |
| }) |
| // Build router lookup map and capture the first available router in a single pass. | ||
| // This reduces subsequent lookups from O(n) each to O(1). | ||
| routers := make(map[string]module.HTTPRouter) | ||
| var firstRouter module.HTTPRouter | ||
| for svcName, svc := range app.SvcRegistry() { | ||
| if router, ok := svc.(module.HTTPRouter); ok { | ||
| routers[svcName] = router | ||
| if firstRouter == nil { | ||
| firstRouter = router | ||
| } | ||
| } |
There was a problem hiding this comment.
Fallback router selection uses the first HTTPRouter encountered while iterating over app.SvcRegistry(), but map iteration order is randomized, so in apps with multiple routers the OpenAPI routes could attach non-deterministically. Prefer selecting deterministically (e.g., by using the first http.router defined in cfg.Modules, or by sorting router service names) and/or require an explicit router when multiple routers exist.
| } else if mediaType != nil && mediaType.Schema != nil && len(bodyBytes) > 0 { | ||
| var bodyData any | ||
| if jsonErr := json.Unmarshal(bodyBytes, &bodyData); jsonErr == nil { | ||
| if bodyErrs := validateJSONBody(bodyData, mediaType.Schema); len(bodyErrs) > 0 { | ||
| errs = append(errs, bodyErrs...) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
If the request body is present and a JSON schema is defined, invalid JSON currently passes validation because json.Unmarshal errors are ignored. Treat JSON decode failures as validation errors (and consider returning a 400 with a helpful error) so malformed payloads don’t reach downstream handlers as “valid”.
|
@copilot open a new pull request to apply changes based on the comments in this thread and #134 (review) and #134 (review) |
…-type, schema, defaults (#150) * Initial plan * fix(openapi): address all remaining review comments — body bytes, JSON errors, content-type, schema, defaults, logging Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
|
@copilot Address all linter, build, and test errors, regardless whether they pre-exist or not. Get the build clean. |
| if h.op.RequestBody.Required && len(bodyBytes) == 0 { | ||
| errs = append(errs, "request body is required but missing") | ||
| } else if mediaType != nil && mediaType.Schema != nil && len(bodyBytes) > 0 { | ||
| var bodyData any | ||
| if jsonErr := json.Unmarshal(bodyBytes, &bodyData); jsonErr != nil { | ||
| errs = append(errs, fmt.Sprintf("request body contains invalid JSON: %v", jsonErr)) | ||
| } else if bodyErrs := validateJSONBody(bodyData, mediaType.Schema); len(bodyErrs) > 0 { | ||
| errs = append(errs, bodyErrs...) | ||
| } |
There was a problem hiding this comment.
Request-body validation only calls validateJSONBody, which validates object schemas but returns nil for non-object root schemas. That means endpoints whose requestBody schema is a primitive/array won’t be validated at all. Consider validating the decoded body against the root schema (e.g., dispatch to validateJSONValue for non-object types, and return a clear error for unsupported schema types).
| types := make([]string, 0, len(content)) | ||
| for ct := range content { | ||
| types = append(types, ct) | ||
| } | ||
| return strings.Join(types, ", ") |
There was a problem hiding this comment.
supportedContentTypes() iterates a map, so the returned list order is nondeterministic. This can produce unstable error messages and make tests/logs harder to diff. Consider sorting the collected content types before joining.
| if v, ok := cfg["base_path"].(string); ok { | ||
| oacfg.BasePath = v | ||
| } |
There was a problem hiding this comment.
The factory leaves BasePath empty unless base_path is provided, but the module schema in this plugin advertises a default base_path of /api/v1. This mismatch can cause configs generated from the schema/UI to behave differently at runtime. Consider either defaulting OpenAPIConfig.BasePath to /api/v1 here, or removing /api/v1 as the schema default so they match.
* Initial plan * fix(cmd): restore missing multiWorkflowAddr flag definition Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
…155) * Initial plan * fix(openapi): address review feedback from thread 3844286430 - Add configurable max body size limit (default 1 MiB) via http.MaxBytesReader to prevent DoS from arbitrarily large request bodies - Use validateJSONValue() for request body validation to handle non-object root schemas (primitives, arrays) that were previously silently skipped - Only register /openapi.yaml endpoint when source spec is YAML; JSON sources already served via /openapi.json - Sort supportedContentTypes() output for deterministic error messages - Remove /api/v1 from plugin schema DefaultConfig to match factory (empty) default - Add server→router mapping in wireOpenAPIRoutes for consistent router discovery when openapi module depends on http.server instead of http.router directly - Tests: add TestOpenAPIModule_JSONSourceNoYAMLEndpoint and TestOpenAPIModule_MaxBodySize Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Summary
openapimodule type that parses OpenAPI v3 YAML/JSON spec filesTest plan
go build ./...compiles cleanlygo test ./module/ -run OpenAPI— all tests passgolangci-lint run— 0 issuesCloses #79
🤖 Generated with Claude Code