Skip to content

feat: add OpenAPI/Swagger spec module for auto-generating HTTP routes#134

Merged
intel352 merged 10 commits intomainfrom
feat/issue-79-openapi
Feb 24, 2026
Merged

feat: add OpenAPI/Swagger spec module for auto-generating HTTP routes#134
intel352 merged 10 commits intomainfrom
feat/issue-79-openapi

Conversation

@intel352
Copy link
Contributor

Summary

  • Add openapi module type that parses OpenAPI v3 YAML/JSON spec files
  • Generate HTTP route handlers from spec paths with method mapping
  • Add request validation against spec schemas (query params, request body)
  • Add optional Swagger UI and spec serving endpoints
  • Add OpenAPI plugin for plugin-based module registration
  • Add example petstore config and OpenAPI spec
  • 5 files, 1568 insertions

Test plan

  • go build ./... compiles cleanly
  • go test ./module/ -run OpenAPI — all tests pass
  • golangci-lint run — 0 issues
  • Tests cover: spec parsing, route generation, request validation, error cases

Closes #79

🤖 Generated with Claude Code

…#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>
Copilot AI review requested due to automatic review settings February 23, 2026 11:51
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +1 to +561
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
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +634 to +642
// htmlEscape escapes a string for safe embedding in HTML attributes/text.
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&#34;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
type: openapi
dependsOn:
- petstore-router
config:
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
config:
config:
# NOTE: spec_file is resolved relative to this config file's directory
# via the _config_dir mechanism in config.ResolvePathInConfig.

Copilot uses AI. Check for mistakes.
Comment on lines +538 to +557
// 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))
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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...)

Copilot uses AI. Check for mistakes.
…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>
@intel352
Copy link
Contributor Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Contributor

Copilot AI commented Feb 23, 2026

@intel352 I've opened a new pull request, #146, to work on those changes. Once the pull request is ready, I'll request review from you.

…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 AI review requested due to automatic review settings February 23, 2026 16:29
@intel352
Copy link
Contributor Author

@copilot open a new pull request to apply changes based on the comments in this thread
Some comments appear to have been addressed already (affected code was touched), so I resolved those, but there are numerous unresolved concerns.

Copy link
Contributor

Copilot AI commented Feb 23, 2026

@intel352 I've opened a new pull request, #149, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

if m.cfg.SwaggerUI.Enabled {
uiPath := m.cfg.SwaggerUI.Path
if uiPath == "" {
uiPath = "/docs"
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
uiPath = "/docs"
uiPath = "/docs"
} else if !strings.HasPrefix(uiPath, "/") {
uiPath = "/" + uiPath

Copilot uses AI. Check for mistakes.
Comment on lines +683 to +686
baseURL := os.Getenv("SWAGGER_UI_ASSETS_BASE_URL")
if baseURL == "" {
baseURL = "https://unpkg.com/swagger-ui-dist@5"
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
return map[string]plugin.ModuleFactory{
"openapi": func(name string, cfg map[string]any) modular.Module {
oacfg := module.OpenAPIConfig{}

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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 = "/"

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +190
if router, ok := svc.(module.HTTPRouter); ok {
routers[svcName] = router
if firstRouter == nil {
firstRouter = router
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +346 to +350
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
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copilot AI and others added 2 commits February 23, 2026 15:14
… 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>
Copilot AI review requested due to automatic review settings February 23, 2026 20:45
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Comment on lines +107 to +108
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"}},
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"}},

Copilot uses AI. Check for mistakes.
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 validation error, got %d: %s", w.Code, w.Body.String())
}
})
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
})
})
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())
}
})

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +196
// 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
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +373 to +380
} 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...)
}
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses AI. Check for mistakes.
@intel352
Copy link
Contributor Author

@copilot open a new pull request to apply changes based on the comments in this thread

and #134 (review)

and #134 (review)

Copy link
Contributor

Copilot AI commented Feb 23, 2026

@intel352 I've opened a new pull request, #150, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 2 commits February 23, 2026 19:18
…-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 AI review requested due to automatic review settings February 24, 2026 00:21
@intel352
Copy link
Contributor Author

@copilot Address all linter, build, and test errors, regardless whether they pre-exist or not. Get the build clean.

Copy link
Contributor

Copilot AI commented Feb 24, 2026

@intel352 I've opened a new pull request, #152, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Comment on lines +383 to +391
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...)
}
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +734 to +738
types := make([]string, 0, len(content))
for ct := range content {
types = append(types, ct)
}
return strings.Join(types, ", ")
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +76
if v, ok := cfg["base_path"].(string); ok {
oacfg.BasePath = v
}
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
* 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>
@intel352
Copy link
Contributor Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Contributor

Copilot AI commented Feb 24, 2026

@intel352 I've opened a new pull request, #155, to work on those changes. Once the pull request is ready, I'll request review from you.

…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>
Copilot AI review requested due to automatic review settings February 24, 2026 01:39
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

@intel352 intel352 merged commit 09b1894 into main Feb 24, 2026
10 of 13 checks passed
@intel352 intel352 deleted the feat/issue-79-openapi branch February 24, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support generating APIs from OpenAPI / Swagger specifications

3 participants