From 62ea690fe77db536eb3b2ad0b74990b9911eb37a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:10:04 +0000 Subject: [PATCH 1/4] Initial plan From 9882960405199148a0c06463adf93369ef9bdf63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:28:20 +0000 Subject: [PATCH 2/4] feat: add OpenAPI response validation with JSON:API support - Extend openAPIResponse with Content/schema for response body validation - Extend openAPISchema with Items, MinItems, MaxItems, AdditionalProperties - Add responseCapturingWriter for buffering responses before validation - Add validateResponse() for checking status, content-type, and body schema - Add writeAndValidatePipelineResponse() for pipeline context responses - Add array type validation in validateJSONValue (items, minItems, maxItems) - Add ResponseAction config field ("warn" logs, "error" rejects with 500) - Add comprehensive tests for response validation scenarios - Add JSON:API complex response validation tests - Update petstore example spec with response schemas - Create JSON:API articles example spec and workflow config - Update DOCUMENTATION.md Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- DOCUMENTATION.md | 1 + example/openapi-jsonapi-articles.yaml | 106 +++ example/openapi-petstore.yaml | 3 +- example/specs/jsonapi-articles.yaml | 189 +++++ example/specs/petstore.yaml | 74 +- module/openapi.go | 325 ++++++++- module/openapi_test.go | 982 ++++++++++++++++++++++++++ 7 files changed, 1656 insertions(+), 24 deletions(-) create mode 100644 example/openapi-jsonapi-articles.yaml create mode 100644 example/specs/jsonapi-articles.yaml diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ed165ed5..a233cf7e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -324,6 +324,7 @@ value: '{{ index .steps "parse-request" "path_params" "id" }}' |------|-------------| | `webhook.sender` | Outbound webhook delivery with retry and dead letter | | `notification.slack` | Slack notifications | +| `openapi` | OpenAPI v3 spec-driven route registration with request and response validation | | `openapi.consumer` | OpenAPI spec consumer for external service integration | | `openapi.generator` | OpenAPI spec generation from workflow config | diff --git a/example/openapi-jsonapi-articles.yaml b/example/openapi-jsonapi-articles.yaml new file mode 100644 index 00000000..7d339455 --- /dev/null +++ b/example/openapi-jsonapi-articles.yaml @@ -0,0 +1,106 @@ +# OpenAPI Response Validation — JSON:API Example +# +# This configuration demonstrates how the workflow engine validates API +# responses against an OpenAPI specification, including complex response +# formats like JSON:API (https://jsonapi.org/). +# +# Key features: +# - Request validation: incoming requests are checked against the spec +# - Response validation: outgoing responses are checked against the spec +# - response_action: "warn" logs violations, "error" rejects them with 500 +# +# The JSON:API spec (specs/jsonapi-articles.yaml) defines a complex nested +# response envelope with required fields (data, type, id, attributes) that +# the engine validates automatically. + +requires: + plugins: + - name: workflow-plugin-http + - name: workflow-plugin-openapi + +modules: + # HTTP server + - name: jsonapi-server + type: http.server + config: + address: ":8096" + + # HTTP router + - name: jsonapi-router + type: http.router + dependsOn: + - jsonapi-server + + # OpenAPI module with response validation enabled + - name: articles-api + type: openapi + dependsOn: + - jsonapi-router + config: + spec_file: specs/jsonapi-articles.yaml + base_path: /api/v1 + router: jsonapi-router + validation: + request: true + response: true + response_action: warn # "warn" = log and pass through; "error" = reject with 500 + swagger_ui: + enabled: true + path: /docs + +# Pipelines that generate JSON:API compliant responses. +# The OpenAPI response validation ensures these conform to the spec. +pipelines: + list-articles: + steps: + - name: build-response + type: step.set + config: + values: + response_status: 200 + response_headers: + Content-Type: "application/vnd.api+json" + response_body: | + { + "data": [ + { + "type": "articles", + "id": "1", + "attributes": { + "title": "Getting Started with Workflow Engine", + "body": "This article explains how to use the workflow engine...", + "created_at": "2024-01-15T10:30:00Z" + }, + "relationships": { + "author": { + "data": {"type": "people", "id": "42"} + } + } + } + ], + "meta": {"total": 1, "page": 1, "per_page": 10}, + "links": {"self": "/api/v1/articles"} + } + + get-article: + steps: + - name: build-response + type: step.set + config: + values: + response_status: 200 + response_headers: + Content-Type: "application/vnd.api+json" + response_body: | + { + "data": { + "type": "articles", + "id": "1", + "attributes": { + "title": "Getting Started with Workflow Engine", + "body": "This article explains how to use the workflow engine...", + "created_at": "2024-01-15T10:30:00Z" + } + }, + "links": {"self": "/api/v1/articles/1"} + } diff --git a/example/openapi-petstore.yaml b/example/openapi-petstore.yaml index c2b6f2d1..c660cc99 100644 --- a/example/openapi-petstore.yaml +++ b/example/openapi-petstore.yaml @@ -29,7 +29,8 @@ modules: router: petstore-router validation: request: true - response: false + response: true + response_action: warn swagger_ui: enabled: true path: /docs diff --git a/example/specs/jsonapi-articles.yaml b/example/specs/jsonapi-articles.yaml new file mode 100644 index 00000000..851dddc4 --- /dev/null +++ b/example/specs/jsonapi-articles.yaml @@ -0,0 +1,189 @@ +openapi: "3.0.0" +info: + title: Articles API (JSON:API) + version: "1.0.0" + description: | + A sample API that returns JSON:API (https://jsonapi.org/) compliant responses. + Demonstrates OpenAPI response validation against a complex JSON:API envelope. + +paths: + /articles: + get: + operationId: listArticles + summary: List all articles + x-pipeline: list-articles + parameters: + - name: page[number] + in: query + required: false + schema: + type: integer + minimum: 1 + - name: page[size] + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + "200": + description: A JSON:API compliant list of articles + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + type: object + required: + - type + - id + - attributes + properties: + type: + type: string + id: + type: string + attributes: + type: object + required: + - title + properties: + title: + type: string + body: + type: string + created_at: + type: string + relationships: + type: object + properties: + author: + type: object + properties: + data: + type: object + required: + - type + - id + properties: + type: + type: string + id: + type: string + included: + type: array + items: + type: object + required: + - type + - id + properties: + type: + type: string + id: + type: string + attributes: + type: object + meta: + type: object + properties: + total: + type: integer + page: + type: integer + per_page: + type: integer + links: + type: object + properties: + self: + type: string + first: + type: string + last: + type: string + next: + type: string + prev: + type: string + + /articles/{id}: + get: + operationId: getArticle + summary: Get a single article + x-pipeline: get-article + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: A single article resource + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + type: object + required: + - type + - id + - attributes + properties: + type: + type: string + id: + type: string + attributes: + type: object + required: + - title + properties: + title: + type: string + body: + type: string + created_at: + type: string + relationships: + type: object + links: + type: object + properties: + self: + type: string + "404": + description: Article not found + content: + application/vnd.api+json: + schema: + type: object + required: + - errors + properties: + errors: + type: array + minItems: 1 + items: + type: object + required: + - status + - title + properties: + status: + type: string + title: + type: string + detail: + type: string diff --git a/example/specs/petstore.yaml b/example/specs/petstore.yaml index b16b9973..19ee52cf 100644 --- a/example/specs/petstore.yaml +++ b/example/specs/petstore.yaml @@ -20,6 +20,25 @@ paths: responses: "200": description: A list of pets + content: + application/json: + schema: + type: array + items: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + minLength: 1 + maxLength: 100 + tag: + type: string + maxLength: 50 "400": description: Invalid request post: @@ -43,7 +62,21 @@ paths: maxLength: 50 responses: "201": - description: Null response + description: Created pet + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + tag: + type: string "400": description: Validation error @@ -60,8 +93,31 @@ paths: responses: "200": description: Expected response to a valid request + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + tag: + type: string "404": description: Pet not found + content: + application/json: + schema: + type: object + required: + - error + properties: + error: + type: string /pets/{petId}/status: put: @@ -91,6 +147,22 @@ paths: responses: "200": description: Updated + content: + application/json: + schema: + type: object + required: + - id + - status + properties: + id: + type: integer + status: + type: string + enum: + - available + - pending + - sold "400": description: Invalid status "404": diff --git a/module/openapi.go b/module/openapi.go index b665c23f..43cd6d8d 100644 --- a/module/openapi.go +++ b/module/openapi.go @@ -24,8 +24,9 @@ import ( // OpenAPIValidationConfig controls which request/response parts are validated. type OpenAPIValidationConfig struct { - Request bool `yaml:"request" json:"request"` - Response bool `yaml:"response" json:"response"` + Request bool `yaml:"request" json:"request"` + Response bool `yaml:"response" json:"response"` + ResponseAction string `yaml:"response_action" json:"response_action"` // "warn" (default) or "error" } // OpenAPISwaggerUIConfig controls Swagger UI hosting. @@ -97,21 +98,26 @@ type openAPIMediaType struct { // openAPIResponse describes a single response entry. type openAPIResponse struct { - Description string `yaml:"description" json:"description"` + Description string `yaml:"description" json:"description"` + Content map[string]openAPIMediaType `yaml:"content" json:"content"` } // openAPISchema is a minimal JSON Schema subset used for parameter/body validation. type openAPISchema struct { - Type string `yaml:"type" json:"type"` - Required []string `yaml:"required" json:"required"` - Properties map[string]*openAPISchema `yaml:"properties" json:"properties"` - Format string `yaml:"format" json:"format"` - Minimum *float64 `yaml:"minimum" json:"minimum"` - Maximum *float64 `yaml:"maximum" json:"maximum"` - MinLength *int `yaml:"minLength" json:"minLength"` - MaxLength *int `yaml:"maxLength" json:"maxLength"` - Pattern string `yaml:"pattern" json:"pattern"` - Enum []any `yaml:"enum" json:"enum"` + Type string `yaml:"type" json:"type"` + Required []string `yaml:"required" json:"required"` + Properties map[string]*openAPISchema `yaml:"properties" json:"properties"` + Format string `yaml:"format" json:"format"` + Minimum *float64 `yaml:"minimum" json:"minimum"` + Maximum *float64 `yaml:"maximum" json:"maximum"` + MinLength *int `yaml:"minLength" json:"minLength"` + MaxLength *int `yaml:"maxLength" json:"maxLength"` + Pattern string `yaml:"pattern" json:"pattern"` + Enum []any `yaml:"enum" json:"enum"` + Items *openAPISchema `yaml:"items" json:"items"` + MinItems *int `yaml:"minItems" json:"minItems"` + MaxItems *int `yaml:"maxItems" json:"maxItems"` + AdditionalProperties *openAPISchema `yaml:"additionalProperties" json:"additionalProperties"` } // ---- OpenAPIModule ---- @@ -289,15 +295,24 @@ func (m *OpenAPIModule) RegisterRoutes(router HTTPRouter) { // buildRouteHandler creates an HTTPHandler that validates the request (if enabled) // and either executes the linked pipeline (if x-pipeline is set) or returns a 501 -// Not Implemented stub response. +// Not Implemented stub response. When response validation is enabled, the handler +// checks the outgoing response body against the OpenAPI response schema and either +// logs a warning or returns a 500 error depending on the response_action setting. func (m *OpenAPIModule) buildRouteHandler(specPath, method string, op *openAPIOperation) HTTPHandler { validateReq := m.cfg.Validation.Request + validateResp := m.cfg.Validation.Response + responseAction := m.cfg.Validation.ResponseAction + if responseAction == "" { + responseAction = "warn" + } h := &openAPIRouteHandler{ - module: m, - specPath: specPath, - method: method, - op: op, - validateReq: validateReq, + module: m, + specPath: specPath, + method: method, + op: op, + validateReq: validateReq, + validateResp: validateResp, + responseAction: responseAction, } if op.XPipeline != "" { h.pipelineName = op.XPipeline @@ -321,6 +336,8 @@ type openAPIRouteHandler struct { method string op *openAPIOperation validateReq bool + validateResp bool + responseAction string // "warn" or "error" pipelineName string pipelineLookup PipelineLookupFn } @@ -361,7 +378,16 @@ func (h *openAPIRouteHandler) Handle(w http.ResponseWriter, r *http.Request) { data := openAPIExtractRequestData(r) - rw := &trackedResponseWriter{ResponseWriter: w} + // When response validation is enabled, wrap the writer with a capturing + // writer so we can inspect the response body/status before sending. + var cw *responseCapturingWriter + var rw *trackedResponseWriter + if h.validateResp { + cw = newResponseCapturingWriter(w) + rw = &trackedResponseWriter{ResponseWriter: cw} + } else { + rw = &trackedResponseWriter{ResponseWriter: w} + } ctx := context.WithValue(r.Context(), HTTPResponseWriterContextKey, rw) ctx = context.WithValue(ctx, HTTPRequestContextKey, r) @@ -376,20 +402,72 @@ func (h *openAPIRouteHandler) Handle(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{ "error": fmt.Sprintf("pipeline execution failed: %v", err), }) + } else if cw != nil { + cw.flush() } return } if rw.written { + // Pipeline wrote directly to the response writer. + if cw != nil { + // Validate the captured response before flushing. + if respErrs := h.validateResponse(cw.statusCode, cw.Header(), cw.body.Bytes()); len(respErrs) > 0 { + if h.responseAction == "error" { + // Discard the buffered response and return a 500 with validation errors. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "response validation failed", + "errors": respErrs, + }) + return + } + h.module.logger.Warn("OpenAPI response validation failed", + "module", h.module.name, + "path", h.specPath, + "method", h.method, + "errors", respErrs, + ) + } + cw.flush() + } return } // If the pipeline set response_status in its output (without writing // directly to the response writer), use those values to build the response. - if writePipelineContextResponse(w, result.Current) { - return + if h.validateResp { + if h.writeAndValidatePipelineResponse(w, result.Current) { + return + } + } else { + if writePipelineContextResponse(w, result.Current) { + return + } } + // Default: 200 with JSON-encoded pipeline state. + respBody, _ := json.Marshal(result.Current) + if h.validateResp { + if respErrs := h.validateResponse(http.StatusOK, http.Header{"Content-Type": []string{"application/json"}}, respBody); len(respErrs) > 0 { + if h.responseAction == "error" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "response validation failed", + "errors": respErrs, + }) + return + } + h.module.logger.Warn("OpenAPI response validation failed", + "module", h.module.name, + "path", h.specPath, + "method", h.method, + "errors", respErrs, + ) + } + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(result.Current) @@ -502,6 +580,190 @@ func (h *openAPIRouteHandler) validate(r *http.Request) []string { return errs } +// ---- Response validation ---- + +// responseCapturingWriter buffers the response body, status code, and headers +// so we can validate them against the OpenAPI spec before sending to the client. +type responseCapturingWriter struct { + underlying http.ResponseWriter + body bytes.Buffer + statusCode int + headerSent bool +} + +func newResponseCapturingWriter(w http.ResponseWriter) *responseCapturingWriter { + return &responseCapturingWriter{ + underlying: w, + statusCode: http.StatusOK, + } +} + +// Header returns the underlying response writer's header map so that callers +// can set headers which will be flushed later. +func (c *responseCapturingWriter) Header() http.Header { + return c.underlying.Header() +} + +// Write captures the response body into an internal buffer. +func (c *responseCapturingWriter) Write(b []byte) (int, error) { + return c.body.Write(b) +} + +// WriteHeader captures the status code without sending it yet. +func (c *responseCapturingWriter) WriteHeader(code int) { + c.statusCode = code +} + +// flush sends the buffered status code, headers, and body to the underlying writer. +func (c *responseCapturingWriter) flush() { + if c.headerSent { + return + } + c.headerSent = true + c.underlying.WriteHeader(c.statusCode) + _, _ = c.underlying.Write(c.body.Bytes()) //nolint:gosec // G705: body is pipeline output, written back to same response +} + +// validateResponse validates the response status code, content type, and body +// against the OpenAPI spec for this operation. Returns a list of validation errors. +func (h *openAPIRouteHandler) validateResponse(statusCode int, headers http.Header, body []byte) []string { + var errs []string + + if h.op.Responses == nil { + return nil + } + + // Look up the response spec by exact status code, then fall back to "default". + statusStr := strconv.Itoa(statusCode) + respSpec, ok := h.op.Responses[statusStr] + if !ok { + // Try wildcard status codes: 2XX, 3XX, etc. + wildcardStatus := string(statusStr[0]) + "XX" + respSpec, ok = h.op.Responses[wildcardStatus] + } + if !ok { + respSpec, ok = h.op.Responses["default"] + } + if !ok { + // No spec defined for this status code — nothing to validate. + return nil + } + + // If no content is defined in the response spec, skip body validation. + if len(respSpec.Content) == 0 { + return nil + } + + // Determine the response content type. + ct := headers.Get("Content-Type") + if idx := strings.Index(ct, ";"); idx >= 0 { + ct = strings.TrimSpace(ct[:idx]) + } + if ct == "" { + ct = "application/json" // default assumption for JSON APIs + } + + mediaType, ok := respSpec.Content[ct] + if !ok { + // Try wildcard content types (e.g., application/*) + for specCT, mt := range respSpec.Content { + if strings.HasSuffix(specCT, "/*") { + prefix := strings.TrimSuffix(specCT, "*") + if strings.HasPrefix(ct, prefix) { + mediaType = mt + ok = true + break + } + } + } + } + if !ok { + errs = append(errs, fmt.Sprintf("response Content-Type %q not defined in spec; spec defines: %s", + ct, supportedContentTypes(respSpec.Content))) + return errs + } + + if mediaType.Schema == nil || len(body) == 0 { + return nil + } + + // Parse and validate the response body against the schema. + var bodyData any + if jsonErr := json.Unmarshal(body, &bodyData); jsonErr != nil { + errs = append(errs, fmt.Sprintf("response body contains invalid JSON: %v", jsonErr)) + return errs + } + + if bodyErrs := validateJSONValue(bodyData, "response", mediaType.Schema); len(bodyErrs) > 0 { + errs = append(errs, bodyErrs...) + } + + return errs +} + +// writeAndValidatePipelineResponse is like writePipelineContextResponse but also +// validates the response against the OpenAPI spec when response validation is enabled. +func (h *openAPIRouteHandler) writeAndValidatePipelineResponse(w http.ResponseWriter, result map[string]any) bool { + rawStatus, ok := result["response_status"] + if !ok { + return false + } + status, ok := coercePipelineStatus(rawStatus) + if !ok { + return false + } + + hdrs := http.Header{} + if rawHeaders, ok := result["response_headers"]; ok { + // Build a temporary header map for validation + switch hv := rawHeaders.(type) { + case map[string]any: + for k, v := range hv { + hdrs.Set(k, fmt.Sprintf("%v", v)) + } + case map[string]string: + for k, v := range hv { + hdrs.Set(k, v) + } + case http.Header: + hdrs = hv + } + } + + var bodyBytes []byte + if body, ok := result["response_body"].(string); ok { + bodyBytes = []byte(body) + } + + if respErrs := h.validateResponse(status, hdrs, bodyBytes); len(respErrs) > 0 { + if h.responseAction == "error" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "response validation failed", + "errors": respErrs, + }) + return true + } + h.module.logger.Warn("OpenAPI response validation failed", + "module", h.module.name, + "path", h.specPath, + "method", h.method, + "errors", respErrs, + ) + } + + // Write the actual response + if rawHeaders, ok := result["response_headers"]; ok { + applyPipelineHeaders(w, rawHeaders) + } + w.WriteHeader(status) + if body, ok := result["response_body"].(string); ok { + _, _ = w.Write([]byte(body)) + } + return true +} + // ---- openAPISpecHandler ---- type openAPISpecHandler struct { @@ -779,6 +1041,25 @@ func validateJSONValue(val any, name string, schema *openAPISchema) []string { if subErrs := validateJSONBody(val, schema); len(subErrs) > 0 { errs = append(errs, subErrs...) } + case "array": + arr, ok := val.([]any) + if !ok { + return []string{fmt.Sprintf("field %q must be an array, got %T", name, val)} + } + if schema.MinItems != nil && len(arr) < *schema.MinItems { + errs = append(errs, fmt.Sprintf("field %q must have at least %d items, got %d", name, *schema.MinItems, len(arr))) + } + if schema.MaxItems != nil && len(arr) > *schema.MaxItems { + errs = append(errs, fmt.Sprintf("field %q must have at most %d items, got %d", name, *schema.MaxItems, len(arr))) + } + if schema.Items != nil { + for i, item := range arr { + itemName := fmt.Sprintf("%s[%d]", name, i) + if itemErrs := validateJSONValue(item, itemName, schema.Items); len(itemErrs) > 0 { + errs = append(errs, itemErrs...) + } + } + } } // Enum validation: use type-aware comparison to prevent e.g. int 1 matching string "1". if len(schema.Enum) > 0 { diff --git a/module/openapi_test.go b/module/openapi_test.go index f9c22fb2..f60816af 100644 --- a/module/openapi_test.go +++ b/module/openapi_test.go @@ -1400,3 +1400,985 @@ func TestOpenAPIModule_XPipeline_ResponseStatus_Float64(t *testing.T) { t.Errorf("unexpected body: %q", w.Body.String()) } } + +// ---- Response validation spec fixtures ---- + +const responseValidationYAML = ` +openapi: "3.0.0" +info: + title: Response Validation API + version: "1.0.0" +paths: + /pets: + get: + operationId: listPets + summary: List all pets + x-pipeline: list-pets-pipeline + responses: + "200": + description: A list of pets + content: + application/json: + schema: + type: array + items: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + minLength: 1 + tag: + type: string + post: + operationId: createPet + summary: Create a pet + x-pipeline: create-pet-pipeline + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + "201": + description: Created pet + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + /pets/{petId}: + get: + operationId: getPet + summary: Get a pet by ID + x-pipeline: get-pet-pipeline + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: A single pet + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + tag: + type: string + "404": + description: Pet not found + content: + application/json: + schema: + type: object + required: + - error + properties: + error: + type: string + /no-response-schema: + get: + operationId: noSchema + summary: Endpoint with no response schema + x-pipeline: no-schema-pipeline + responses: + "200": + description: No content schema defined +` + +// JSON:API style response spec for complex validation scenarios +const jsonAPIResponseYAML = ` +openapi: "3.0.0" +info: + title: JSON:API Response Validation + version: "1.0.0" +paths: + /articles: + get: + operationId: listArticles + summary: List articles (JSON:API format) + x-pipeline: list-articles-pipeline + responses: + "200": + description: JSON:API compliant response + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + type: object + required: + - type + - id + - attributes + properties: + type: + type: string + id: + type: string + attributes: + type: object + required: + - title + properties: + title: + type: string + body: + type: string + relationships: + type: object + properties: + author: + type: object + properties: + data: + type: object + required: + - type + - id + properties: + type: + type: string + id: + type: string + meta: + type: object + properties: + total: + type: integer + links: + type: object + properties: + self: + type: string + next: + type: string +` + +// ---- Response validation tests ---- + +func TestOpenAPIModule_ResponseValidation_ValidResponse(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Pipeline returns a valid array of pets + step := &stubPipelineStep{ + name: "list-pets", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[{"id":1,"name":"Fido","tag":"dog"},{"id":2,"name":"Whiskers"}]`, + "response_headers": map[string]any{ + "Content-Type": "application/json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-pets-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-pets-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/pets") + if h == nil { + t.Fatal("GET /api/pets handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/pets", nil) + h.Handle(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_InvalidResponse_ErrorAction(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Pipeline returns a pet missing the required "name" field + step := &stubPipelineStep{ + name: "list-pets", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[{"id":1}]`, + "response_headers": map[string]any{ + "Content-Type": "application/json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-pets-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-pets-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/pets") + if h == nil { + t.Fatal("GET /api/pets handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/pets", nil) + h.Handle(w, r) + + // With action=error, we expect a 500 response with validation errors + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("expected JSON error body: %v", err) + } + if resp["error"] != "response validation failed" { + t.Errorf("expected 'response validation failed' error, got %v", resp["error"]) + } + errs, ok := resp["errors"].([]any) + if !ok || len(errs) == 0 { + t.Errorf("expected validation errors, got %v", resp["errors"]) + } +} + +func TestOpenAPIModule_ResponseValidation_InvalidResponse_WarnAction(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "warn"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Pipeline returns a pet missing the required "name" field + step := &stubPipelineStep{ + name: "list-pets", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[{"id":1}]`, + "response_headers": map[string]any{ + "Content-Type": "application/json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-pets-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-pets-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/pets") + if h == nil { + t.Fatal("GET /api/pets handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/pets", nil) + h.Handle(w, r) + + // With action=warn, the response should still be sent (200) + if w.Code != http.StatusOK { + t.Errorf("expected 200 (warning only), got %d: %s", w.Code, w.Body.String()) + } + // Body should be the original pipeline body + if w.Body.String() != `[{"id":1}]` { + t.Errorf("expected original pipeline body, got %q", w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_DefaultFallback_Valid(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Pipeline returns output without response_status — falls through to 200 default + step := &stubPipelineStep{ + name: "list-pets", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "result": []any{ + map[string]any{"id": float64(1), "name": "Fido"}, + }, + }, + }, nil + }, + } + pipe := &Pipeline{Name: "list-pets-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-pets-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/pets") + if h == nil { + t.Fatal("GET /api/pets handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/pets", nil) + h.Handle(w, r) + + // The spec expects an array at the top level, but we're sending an object + // (the full pipeline state). This should fail validation in error mode. + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 (response is object, spec expects array), got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_NoSchema_Passes(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + step := &stubPipelineStep{ + name: "no-schema", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `{"anything":"goes"}`, + "response_headers": map[string]any{ + "Content-Type": "application/json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "no-schema-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "no-schema-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/no-response-schema") + if h == nil { + t.Fatal("GET /api/no-response-schema handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/no-response-schema", nil) + h.Handle(w, r) + + // No schema defined — response should pass through + if w.Code != http.StatusOK { + t.Errorf("expected 200 (no schema to validate against), got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_JSONAPI_ValidResponse(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", jsonAPIResponseYAML) + + mod := NewOpenAPIModule("jsonapi", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // A valid JSON:API response + validBody := `{ + "data": [ + { + "type": "articles", + "id": "1", + "attributes": { + "title": "Hello World", + "body": "This is my first article." + }, + "relationships": { + "author": { + "data": {"type": "people", "id": "42"} + } + } + } + ], + "meta": {"total": 1}, + "links": {"self": "/articles"} + }` + + step := &stubPipelineStep{ + name: "list-articles", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": validBody, + "response_headers": map[string]any{ + "Content-Type": "application/vnd.api+json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-articles-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-articles-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/articles") + if h == nil { + t.Fatal("GET /api/articles handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/articles", nil) + h.Handle(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 for valid JSON:API response, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_JSONAPI_InvalidResponse(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", jsonAPIResponseYAML) + + mod := NewOpenAPIModule("jsonapi", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Invalid JSON:API response — missing required "type" and "attributes" in data items + invalidBody := `{ + "data": [ + { + "id": "1" + } + ] + }` + + step := &stubPipelineStep{ + name: "list-articles", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": invalidBody, + "response_headers": map[string]any{ + "Content-Type": "application/vnd.api+json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-articles-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-articles-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/articles") + if h == nil { + t.Fatal("GET /api/articles handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/articles", nil) + h.Handle(w, r) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for invalid JSON:API response, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("expected JSON error body: %v", err) + } + errs, ok := resp["errors"].([]any) + if !ok || len(errs) == 0 { + t.Fatalf("expected validation errors, got %v", resp["errors"]) + } + + // Check that it caught missing required fields + errStr := strings.Join(func() []string { + ss := make([]string, len(errs)) + for i, e := range errs { + ss[i] = e.(string) + } + return ss + }(), " ") + if !strings.Contains(errStr, "type") { + t.Errorf("expected error about missing 'type' field, got: %s", errStr) + } + if !strings.Contains(errStr, "attributes") { + t.Errorf("expected error about missing 'attributes' field, got: %s", errStr) + } +} + +func TestOpenAPIModule_ResponseValidation_JSONAPI_WrongContentType(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", jsonAPIResponseYAML) + + mod := NewOpenAPIModule("jsonapi", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Response with wrong content type (application/json instead of application/vnd.api+json) + step := &stubPipelineStep{ + name: "list-articles", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `{"data":[]}`, + "response_headers": map[string]any{ + "Content-Type": "application/json", + }, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-articles-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-articles-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/articles") + if h == nil { + t.Fatal("GET /api/articles handler not found") + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/articles", nil) + h.Handle(w, r) + + // Should fail because the Content-Type doesn't match the spec + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for wrong content type, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_DirectWrite(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Pipeline step writes directly to the response writer with an invalid response + step := &stubPipelineStep{ + name: "create-pet", + exec: func(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + rw := ctx.Value(HTTPResponseWriterContextKey).(http.ResponseWriter) + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusCreated) + _, _ = rw.Write([]byte(`{"wrong":"fields"}`)) + return &StepResult{Output: map[string]any{}}, nil + }, + } + pipe := &Pipeline{Name: "create-pet-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "create-pet-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("POST", "/api/pets") + if h == nil { + t.Fatal("POST /api/pets handler not found") + } + + w := httptest.NewRecorder() + body := strings.NewReader(`{"name":"Fido"}`) + r := httptest.NewRequest(http.MethodPost, "/api/pets", body) + r.Header.Set("Content-Type", "application/json") + h.Handle(w, r) + + // With error action, the invalid response should be blocked + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for invalid direct-write response, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_DirectWrite_Valid(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + // Pipeline step writes a valid response directly + step := &stubPipelineStep{ + name: "create-pet", + exec: func(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + rw := ctx.Value(HTTPResponseWriterContextKey).(http.ResponseWriter) + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusCreated) + _, _ = rw.Write([]byte(`{"id":1,"name":"Fido"}`)) + return &StepResult{Output: map[string]any{}}, nil + }, + } + pipe := &Pipeline{Name: "create-pet-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "create-pet-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("POST", "/api/pets") + if h == nil { + t.Fatal("POST /api/pets handler not found") + } + + w := httptest.NewRecorder() + body := strings.NewReader(`{"name":"Fido"}`) + r := httptest.NewRequest(http.MethodPost, "/api/pets", body) + r.Header.Set("Content-Type", "application/json") + h.Handle(w, r) + + if w.Code != http.StatusCreated { + t.Errorf("expected 201 for valid response, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOpenAPIModule_ResponseValidation_ArrayConstraints(t *testing.T) { + const arrayConstraintYAML = ` +openapi: "3.0.0" +info: + title: Array Constraint API + version: "1.0.0" +paths: + /items: + get: + operationId: listItems + x-pipeline: list-items + responses: + "200": + description: Items list + content: + application/json: + schema: + type: array + minItems: 1 + maxItems: 3 + items: + type: object + required: + - name + properties: + name: + type: string +` + specPath := writeTempSpec(t, ".yaml", arrayConstraintYAML) + + t.Run("too_few_items", func(t *testing.T) { + mod := NewOpenAPIModule("arr-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + step := &stubPipelineStep{ + name: "list-items", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[]`, + "response_headers": map[string]any{"Content-Type": "application/json"}, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-items", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-items" { + return pipe, true + } + return nil, false + }) + router := &testRouter{} + mod.RegisterRoutes(router) + h := router.findHandler("GET", "/api/items") + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/items", nil) + h.Handle(w, r) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for too few items, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("too_many_items", func(t *testing.T) { + mod := NewOpenAPIModule("arr-api2", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + step := &stubPipelineStep{ + name: "list-items", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[{"name":"a"},{"name":"b"},{"name":"c"},{"name":"d"}]`, + "response_headers": map[string]any{"Content-Type": "application/json"}, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-items", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-items" { + return pipe, true + } + return nil, false + }) + router := &testRouter{} + mod.RegisterRoutes(router) + h := router.findHandler("GET", "/api/items") + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/items", nil) + h.Handle(w, r) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for too many items, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("valid_array", func(t *testing.T) { + mod := NewOpenAPIModule("arr-api3", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true, ResponseAction: "error"}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + step := &stubPipelineStep{ + name: "list-items", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[{"name":"a"},{"name":"b"}]`, + "response_headers": map[string]any{"Content-Type": "application/json"}, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-items", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-items" { + return pipe, true + } + return nil, false + }) + router := &testRouter{} + mod.RegisterRoutes(router) + h := router.findHandler("GET", "/api/items") + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/items", nil) + h.Handle(w, r) + if w.Code != http.StatusOK { + t.Errorf("expected 200 for valid array, got %d: %s", w.Code, w.Body.String()) + } + }) +} + +func TestOpenAPIModule_ResponseValidation_DefaultAction_IsWarn(t *testing.T) { + specPath := writeTempSpec(t, ".yaml", responseValidationYAML) + + // No ResponseAction specified — should default to "warn" + mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ + SpecFile: specPath, + BasePath: "/api", + Validation: OpenAPIValidationConfig{Response: true}, + }) + if err := mod.Init(nil); err != nil { + t.Fatalf("Init: %v", err) + } + + step := &stubPipelineStep{ + name: "list-pets", + exec: func(_ context.Context, _ *PipelineContext) (*StepResult, error) { + return &StepResult{ + Output: map[string]any{ + "response_status": 200, + "response_body": `[{"id":1}]`, // missing required "name" + "response_headers": map[string]any{"Content-Type": "application/json"}, + }, + Stop: true, + }, nil + }, + } + pipe := &Pipeline{Name: "list-pets-pipeline", Steps: []PipelineStep{step}} + mod.SetPipelineLookup(func(name string) (*Pipeline, bool) { + if name == "list-pets-pipeline" { + return pipe, true + } + return nil, false + }) + + router := &testRouter{} + mod.RegisterRoutes(router) + + h := router.findHandler("GET", "/api/pets") + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/pets", nil) + h.Handle(w, r) + + // Default action is warn, so response should pass through + if w.Code != http.StatusOK { + t.Errorf("expected 200 with default warn action, got %d: %s", w.Code, w.Body.String()) + } +} From ef2128d446387c5e31b9fca389423d0074206dd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:22:36 +0000 Subject: [PATCH 3/4] fix: address PR review comments for OpenAPI response validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - responseCapturingWriter uses own header map to prevent header leakage when validation fails and a 500 is returned instead of the pipeline response - validateJSONBody accepts bodyLabel parameter so error messages correctly say 'request body' or 'response body' depending on context (was always hardcoded to 'request body') - Implement additionalProperties validation in validateJSONBody — keys not in Properties are now validated against AdditionalProperties schema when defined - Rename TestOpenAPIModule_ResponseValidation_DefaultFallback_Valid to TestOpenAPIModule_ResponseValidation_DefaultFallback_InvalidFallback (test asserts 500 for schema mismatch, not a valid response) - Add nil guards for router.findHandler() in all 4 array constraint and warn-action subtests Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/openapi.go | 44 ++++++++++++++++++++++++++++++++---------- module/openapi_test.go | 14 +++++++++++++- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/module/openapi.go b/module/openapi.go index 43cd6d8d..2d541cc9 100644 --- a/module/openapi.go +++ b/module/openapi.go @@ -569,7 +569,7 @@ func (h *openAPIRouteHandler) validate(r *http.Request) []string { 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 := validateJSONValue(bodyData, "body", mediaType.Schema); len(bodyErrs) > 0 { + } else if bodyErrs := validateJSONValue(bodyData, "request body", mediaType.Schema); len(bodyErrs) > 0 { errs = append(errs, bodyErrs...) } } @@ -584,8 +584,11 @@ func (h *openAPIRouteHandler) validate(r *http.Request) []string { // responseCapturingWriter buffers the response body, status code, and headers // so we can validate them against the OpenAPI spec before sending to the client. +// It uses its own header map to prevent leaked headers reaching the client when +// validation fails and a different (500) response needs to be sent. type responseCapturingWriter struct { underlying http.ResponseWriter + headers http.Header // own header map; copied to underlying only on flush body bytes.Buffer statusCode int headerSent bool @@ -594,14 +597,15 @@ type responseCapturingWriter struct { func newResponseCapturingWriter(w http.ResponseWriter) *responseCapturingWriter { return &responseCapturingWriter{ underlying: w, + headers: make(http.Header), statusCode: http.StatusOK, } } -// Header returns the underlying response writer's header map so that callers -// can set headers which will be flushed later. +// Header returns this writer's own header map so that callers can set headers +// which are only forwarded to the underlying writer when flush() is called. func (c *responseCapturingWriter) Header() http.Header { - return c.underlying.Header() + return c.headers } // Write captures the response body into an internal buffer. @@ -614,12 +618,18 @@ func (c *responseCapturingWriter) WriteHeader(code int) { c.statusCode = code } -// flush sends the buffered status code, headers, and body to the underlying writer. +// flush copies captured headers and sends the buffered status code and body to the underlying writer. func (c *responseCapturingWriter) flush() { if c.headerSent { return } c.headerSent = true + // Copy captured headers to the underlying writer before sending the status code. + for k, vals := range c.headers { + for _, v := range vals { + c.underlying.Header().Add(k, v) + } + } c.underlying.WriteHeader(c.statusCode) _, _ = c.underlying.Write(c.body.Bytes()) //nolint:gosec // G705: body is pipeline output, written back to same response } @@ -694,7 +704,7 @@ func (h *openAPIRouteHandler) validateResponse(statusCode int, headers http.Head return errs } - if bodyErrs := validateJSONValue(bodyData, "response", mediaType.Schema); len(bodyErrs) > 0 { + if bodyErrs := validateJSONValue(bodyData, "response body", mediaType.Schema); len(bodyErrs) > 0 { errs = append(errs, bodyErrs...) } @@ -942,19 +952,21 @@ func validateScalarValue(val, name, kind string, schema *openAPISchema) []string } // validateJSONBody validates a decoded JSON body against an object schema. -func validateJSONBody(body any, schema *openAPISchema) []string { +// The bodyLabel parameter (e.g. "request body" or "response body") is used in +// error messages to distinguish validation context. +func validateJSONBody(body any, schema *openAPISchema, bodyLabel string) []string { var errs []string obj, ok := body.(map[string]any) if !ok { if schema.Type == "object" { - return []string{"request body must be a JSON object"} + return []string{bodyLabel + " must be a JSON object"} } return nil } // Check required fields for _, req := range schema.Required { if _, present := obj[req]; !present { - errs = append(errs, fmt.Sprintf("request body: required field %q is missing", req)) + errs = append(errs, fmt.Sprintf("%s: required field %q is missing", bodyLabel, req)) } } // Validate individual properties @@ -967,6 +979,18 @@ func validateJSONBody(body any, schema *openAPISchema) []string { errs = append(errs, fieldErrs...) } } + // Validate additionalProperties: keys not declared in Properties are checked + // against the additionalProperties schema when it is specified. + if schema.AdditionalProperties != nil { + for key, val := range obj { + if _, defined := schema.Properties[key]; defined { + continue + } + if fieldErrs := validateJSONValue(val, key, schema.AdditionalProperties); len(fieldErrs) > 0 { + errs = append(errs, fieldErrs...) + } + } + } return errs } @@ -1038,7 +1062,7 @@ func validateJSONValue(val any, name string, schema *openAPISchema) []string { errs = append(errs, fmt.Sprintf("field %q must be a boolean, got %T", name, val)) } case "object": - if subErrs := validateJSONBody(val, schema); len(subErrs) > 0 { + if subErrs := validateJSONBody(val, schema, name); len(subErrs) > 0 { errs = append(errs, subErrs...) } case "array": diff --git a/module/openapi_test.go b/module/openapi_test.go index f60816af..276bad30 100644 --- a/module/openapi_test.go +++ b/module/openapi_test.go @@ -1766,7 +1766,7 @@ func TestOpenAPIModule_ResponseValidation_InvalidResponse_WarnAction(t *testing. } } -func TestOpenAPIModule_ResponseValidation_DefaultFallback_Valid(t *testing.T) { +func TestOpenAPIModule_ResponseValidation_DefaultFallback_InvalidFallback(t *testing.T) { specPath := writeTempSpec(t, ".yaml", responseValidationYAML) mod := NewOpenAPIModule("resp-api", OpenAPIConfig{ @@ -2246,6 +2246,9 @@ paths: router := &testRouter{} mod.RegisterRoutes(router) h := router.findHandler("GET", "/api/items") + if h == nil { + t.Fatal("route GET /api/items not registered") + } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/items", nil) h.Handle(w, r) @@ -2286,6 +2289,9 @@ paths: router := &testRouter{} mod.RegisterRoutes(router) h := router.findHandler("GET", "/api/items") + if h == nil { + t.Fatal("route GET /api/items not registered") + } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/items", nil) h.Handle(w, r) @@ -2326,6 +2332,9 @@ paths: router := &testRouter{} mod.RegisterRoutes(router) h := router.findHandler("GET", "/api/items") + if h == nil { + t.Fatal("route GET /api/items not registered") + } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/items", nil) h.Handle(w, r) @@ -2373,6 +2382,9 @@ func TestOpenAPIModule_ResponseValidation_DefaultAction_IsWarn(t *testing.T) { mod.RegisterRoutes(router) h := router.findHandler("GET", "/api/pets") + if h == nil { + t.Fatal("handler for GET /api/pets not found") + } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/pets", nil) h.Handle(w, r) From 6ce65029e8f18f4e653e22f2e225efd0bcf29d87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:35:46 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20resolve=20CI=20failures=20=E2=80=94?= =?UTF-8?q?=20lint=20tagged=20switch=20and=20example=20go=20mod=20tidy?= 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> --- example/go.mod | 14 +++++++------- example/go.sum | 28 ++++++++++++++-------------- modernize/rules.go | 6 +++--- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/example/go.mod b/example/go.mod index b00541e5..ae6989fb 100644 --- a/example/go.mod +++ b/example/go.mod @@ -5,7 +5,7 @@ go 1.26.0 replace github.com/GoCodeAlone/workflow => ../ require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.3 github.com/GoCodeAlone/workflow v0.0.0-00010101000000-000000000000 ) @@ -20,12 +20,12 @@ require ( cloud.google.com/go/storage v1.60.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect - github.com/GoCodeAlone/modular/modules/auth v1.12.0 // indirect - github.com/GoCodeAlone/modular/modules/cache v1.12.0 // indirect - github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.0 // indirect - github.com/GoCodeAlone/modular/modules/jsonschema v1.12.0 // indirect - github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.5.0 // indirect - github.com/GoCodeAlone/modular/modules/scheduler v1.12.0 // indirect + github.com/GoCodeAlone/modular/modules/auth v1.14.0 // indirect + github.com/GoCodeAlone/modular/modules/cache v1.14.0 // indirect + github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.7.0 // indirect + github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0 // indirect + github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0 // indirect + github.com/GoCodeAlone/modular/modules/scheduler v1.14.0 // indirect github.com/GoCodeAlone/yaegi v0.17.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect diff --git a/example/go.sum b/example/go.sum index f1c81b9c..b872c630 100644 --- a/example/go.sum +++ b/example/go.sum @@ -30,20 +30,20 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= -github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= -github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= -github.com/GoCodeAlone/modular/modules/auth v1.12.0 h1:eO4iq8tkz8W5sLKRSG5dC+ACITMtxZrtSJ+ReE3fKdA= -github.com/GoCodeAlone/modular/modules/auth v1.12.0/go.mod h1:D+yfkgN3MTkyl1xe8h2UL7uqB9Vj1lO3wUrscfnJ/NU= -github.com/GoCodeAlone/modular/modules/cache v1.12.0 h1:Ue6aXytFq1I+OnC3PcV2KlUg4lHiuGWH0Qq+v/lqyp0= -github.com/GoCodeAlone/modular/modules/cache v1.12.0/go.mod h1:kSaT8wNy/3YGmtIpDqPbW6MRqKOp2yc8a5MHdAag2CE= -github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.0 h1:K6X+X1sOq+lpI1Oa+XUzH+GlSRYJQfDTTcvMjZfkbFU= -github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.0/go.mod h1:Q0TpCFTtd0q20okDyi63ALS+1xmkYU4wNUOqwczyih0= -github.com/GoCodeAlone/modular/modules/jsonschema v1.12.0 h1:urGK8Xtwku4tn8nBeVZn9UqvldnCptZ3rLCXO21vSz4= -github.com/GoCodeAlone/modular/modules/jsonschema v1.12.0/go.mod h1:+/0p1alfSbhhshcNRId1HRRIupeu0DPC7BH8AYiBQ1I= -github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.5.0 h1:zcF46oZ7MJFfZCmzqc1n9ZTw6wrTJSFr04yaz6EYKeo= -github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.5.0/go.mod h1:ycmJYst0dgaeLYBDOFGYz3ZiVK0fVcbl59omBySpKis= -github.com/GoCodeAlone/modular/modules/scheduler v1.12.0 h1:kxeLUpFFZ2HWV5B7Ra1WaOr1DDee5G6kAZ6F1BUXX/Y= -github.com/GoCodeAlone/modular/modules/scheduler v1.12.0/go.mod h1:VpDSAU0Guj8geVz19YCSknyCJp0j3TMBaxLEYXedkZc= +github.com/GoCodeAlone/modular v1.12.3 h1:WcNqc1ZG+Lv/xzF8wTDavGIOeAvlV4wEd5HO2mVTUwE= +github.com/GoCodeAlone/modular v1.12.3/go.mod h1:nDdyW/eJu4gDFNueb6vWwLvti3bPHSZJHkWGiwEmi2I= +github.com/GoCodeAlone/modular/modules/auth v1.14.0 h1:Y+p4/HIcxkajlcNhcPlqpwAt1SCHjB4AaDMEys50E3I= +github.com/GoCodeAlone/modular/modules/auth v1.14.0/go.mod h1:fkwPn2svDsCHBI19gtUHxo064SL+EudjB+o7VjL9ug8= +github.com/GoCodeAlone/modular/modules/cache v1.14.0 h1:ykQRwXJGXaRtAsnW9Tgs0LvXExonkKr8P7XIHxPaYdY= +github.com/GoCodeAlone/modular/modules/cache v1.14.0/go.mod h1:tcIjHJHZ5fVU8sstILrXeVQgjpZcUkErnNjRaxkBSR8= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.7.0 h1:clGAyaOfyDc9iY63ONfZiHReVccVhK/yH19QEb14SSI= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.7.0/go.mod h1:0AnfWGVmrqyv91rduc6mrPqW6WQchDAa2WtM0Qmw/WA= +github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0 h1:dCiPIO+NvJPizfCeUQqGXHD1WitOVYpKuL3fxMEjRlw= +github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0/go.mod h1:5Hm+R9G41wwb0hKefx9+9PMqffjU1tA7roW3t3sTaLE= +github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0 h1:TtVD+tE8ABN98n50MFVyMAvMsBM4JE86KRgCRDzPDC4= +github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0/go.mod h1:N7d8aSV4eqr90qjlIOs/8EmW7avt9gwX06Uh+zKDr4s= +github.com/GoCodeAlone/modular/modules/scheduler v1.14.0 h1:JSrzo4FB7uGASExv+fCLRd6pXWULV1mJYvzmM9PzUeM= +github.com/GoCodeAlone/modular/modules/scheduler v1.14.0/go.mod h1:emkR2AnilabLJZv1rOTDO9eGpRBmZs487H00Lnp9jIc= github.com/GoCodeAlone/yaegi v0.17.1 h1:aPAwU29L9cGceRAff02c5pjQcT5KapDB4fWFZK9tElE= github.com/GoCodeAlone/yaegi v0.17.1/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= diff --git a/modernize/rules.go b/modernize/rules.go index 72702291..55763857 100644 --- a/modernize/rules.go +++ b/modernize/rules.go @@ -210,11 +210,11 @@ func hyphenStepsFixConfig(cfg *yaml.Node, renames map[string]string, changes *[] key := cfg.Content[i] val := cfg.Content[i+1] - switch { - case val.Kind == yaml.MappingNode: + switch val.Kind { + case yaml.MappingNode: // Recurse into nested maps (e.g., routes map in step.conditional) hyphenStepsFixConfig(val, renames, changes) - case val.Kind == yaml.ScalarNode: + case yaml.ScalarNode: for oldName, newName := range renames { updated := hyphenStepsFixScalar(key.Value, val.Value, oldName, newName) if updated != val.Value {