From d137b6851e66dfc0356c636826632d0ec0f815bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:53:17 +0000 Subject: [PATCH 1/2] Initial plan From 0e599cc8e2240f3aa640ca3544ffbdb8accfa7b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:09:46 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(openapi):=20address=20all=20remaining?= =?UTF-8?q?=20review=20comments=20=E2=80=94=20body=20bytes,=20JSON=20error?= =?UTF-8?q?s,=20content-type,=20schema,=20defaults,=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/openapi.go | 42 +++++++++++++++++++++++++++++---------- module/openapi_test.go | 11 ++++++++++ plugins/openapi/plugin.go | 12 +++++++++-- schema/module_schema.go | 8 +++----- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/module/openapi.go b/module/openapi.go index eb5d92c8..9698a0cc 100644 --- a/module/openapi.go +++ b/module/openapi.go @@ -1,6 +1,7 @@ package module import ( + "bytes" "context" "encoding/json" "fmt" @@ -237,10 +238,14 @@ func (m *OpenAPIModule) RegisterRoutes(router HTTPRouter) { // JSON endpoint: serve re-serialised spec as JSON. router.AddRoute(http.MethodGet, specPathJSON, &openAPISpecHandler{specJSON: m.specJSON}) - // YAML endpoint: serve the original spec bytes with a YAML content-type. - // This preserves the source format; if the original file was YAML it is - // served as YAML, and if it was JSON it is served as-is (JSON is valid YAML). - router.AddRoute(http.MethodGet, specPathYAML, &openAPIRawSpecHandler{specBytes: m.specBytes, contentType: "application/yaml"}) + // YAML endpoint: serve the original spec bytes with a content-type that + // matches the source format. JSON source files are served as application/json; + // YAML source files are served as application/yaml. + rawContentType := "application/yaml" + if trimmed := strings.TrimSpace(string(m.specBytes)); len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') { + rawContentType = "application/json" + } + router.AddRoute(http.MethodGet, specPathYAML, &openAPIRawSpecHandler{specBytes: m.specBytes, contentType: rawContentType}) m.logger.Debug("OpenAPI spec endpoint registered", "module", m.name, "paths", []string{specPathJSON, specPathYAML}) } @@ -250,6 +255,8 @@ func (m *OpenAPIModule) RegisterRoutes(router HTTPRouter) { uiPath := m.cfg.SwaggerUI.Path if uiPath == "" { uiPath = "/docs" + } else if !strings.HasPrefix(uiPath, "/") { + uiPath = "/" + uiPath } uiRoutePath := basePath + uiPath specURL := basePath + "/openapi.json" @@ -352,6 +359,10 @@ func (h *openAPIRouteHandler) validate(r *http.Request) []string { // application/octet-stream, but this engine is primarily used for JSON APIs and this // default simplifies client usage. mediaType = &mt + } else if ct != "" && len(h.op.RequestBody.Content) > 0 { + // Content-Type is present but not listed in the spec — reject with 400. + errs = append(errs, fmt.Sprintf("unsupported Content-Type %q; spec defines: %s", + ct, supportedContentTypes(h.op.RequestBody.Content))) } // Read the body once so we can both check for presence (when required) @@ -365,17 +376,18 @@ func (h *openAPIRouteHandler) validate(r *http.Request) []string { ) errs = append(errs, "failed to read request body") } else { - // Always restore body for downstream handlers. - r.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + // Always restore body for downstream handlers using the original byte slice + // to avoid a bytes→string→bytes conversion that could corrupt non-UTF-8 payloads. + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) 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 { - if bodyErrs := validateJSONBody(bodyData, mediaType.Schema); len(bodyErrs) > 0 { - errs = append(errs, bodyErrs...) - } + 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...) } } } @@ -715,3 +727,13 @@ func swaggerUIHTML(title, specURL string) string { func htmlEscape(s string) string { return html.EscapeString(s) } + +// supportedContentTypes returns a comma-joined list of content types defined +// in the requestBody.content map, used in validation error messages. +func supportedContentTypes(content map[string]openAPIMediaType) string { + types := make([]string, 0, len(content)) + for ct := range content { + types = append(types, ct) + } + return strings.Join(types, ", ") +} diff --git a/module/openapi_test.go b/module/openapi_test.go index e8f0cadb..c7c867e0 100644 --- a/module/openapi_test.go +++ b/module/openapi_test.go @@ -416,6 +416,17 @@ func TestOpenAPIModule_RequestValidation_Body(t *testing.T) { t.Errorf("expected 400 validation error, got %d: %s", w.Code, w.Body.String()) } }) + + 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()) + } + }) } func TestOpenAPIModule_NoValidation(t *testing.T) { diff --git a/plugins/openapi/plugin.go b/plugins/openapi/plugin.go index c9f5d90c..dd8d4be5 100644 --- a/plugins/openapi/plugin.go +++ b/plugins/openapi/plugin.go @@ -4,6 +4,8 @@ package openapi import ( + "log/slog" + "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow/capability" "github.com/GoCodeAlone/workflow/config" @@ -56,7 +58,10 @@ func (p *Plugin) Capabilities() []capability.Contract { func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { return map[string]plugin.ModuleFactory{ "openapi": func(name string, cfg map[string]any) modular.Module { - oacfg := module.OpenAPIConfig{} + oacfg := module.OpenAPIConfig{ + // Default: enable request validation unless explicitly overridden. + Validation: module.OpenAPIValidationConfig{Request: true}, + } // NOTE: spec_file existence is not validated here at configuration time. // Path resolution is performed by ResolvePathInConfig (relative to the @@ -227,7 +232,10 @@ func wireOpenAPIRoutes(app modular.Application, cfg *config.WorkflowConfig) erro } if targetRouter == nil { - // No router found — log and skip (not fatal; engine may be running without HTTP) + // No router found — log a warning and skip (not fatal; engine may be running without HTTP). + slog.Warn("openapi: no HTTP router found; skipping route registration", + "module", oaMod.Name(), + ) continue } diff --git a/schema/module_schema.go b/schema/module_schema.go index 66b5de59..158e0487 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -790,11 +790,9 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { ConfigFields: []ConfigFieldDef{ {Key: "spec_file", Label: "Spec File", Type: FieldTypeFilePath, Required: true, Description: "Path to the OpenAPI v3 spec file (JSON or YAML)", Placeholder: "specs/petstore.yaml"}, {Key: "base_path", Label: "Base Path", Type: FieldTypeString, Description: "Base path prefix for all generated routes", Placeholder: "/api/v1"}, - {Key: "router", Label: "Router Module", Type: FieldTypeString, Required: true, Description: "Name of the http.router module to register routes on", Placeholder: "my-router"}, - {Key: "validation.request", Label: "Validate Requests", Type: FieldTypeBool, DefaultValue: true, Description: "Enable request validation against the OpenAPI schema"}, - {Key: "validation.response", Label: "Validate Responses", Type: FieldTypeBool, DefaultValue: false, Description: "Enable response validation against the OpenAPI schema"}, - {Key: "swagger_ui.enabled", Label: "Enable Swagger UI", Type: FieldTypeBool, DefaultValue: false, Description: "Serve Swagger UI for interactive API documentation"}, - {Key: "swagger_ui.path", Label: "Swagger UI Path", Type: FieldTypeString, DefaultValue: "/docs", Description: "URL path for the Swagger UI", Placeholder: "/docs"}, + {Key: "router", Label: "Router Module", Type: FieldTypeString, Description: "Name of the http.router module to register routes on (auto-detected if omitted)", Placeholder: "my-router"}, + {Key: "validation", Label: "Validation", Type: FieldTypeJSON, DefaultValue: map[string]any{"request": true, "response": false}, Description: "Request/response validation settings, e.g. {\"request\": true, \"response\": false}"}, + {Key: "swagger_ui", Label: "Swagger UI", Type: FieldTypeJSON, DefaultValue: map[string]any{"enabled": false, "path": "/docs"}, Description: "Swagger UI settings, e.g. {\"enabled\": false, \"path\": \"/docs\"}"}, }, DefaultConfig: map[string]any{"validation": map[string]any{"request": true, "response": false}, "swagger_ui": map[string]any{"enabled": false, "path": "/docs"}}, })