Skip to content

Commit cee546a

Browse files
Copilotintel352
andauthored
fix(openapi): address remaining review comments — validation, content-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>
1 parent 8b469c7 commit cee546a

4 files changed

Lines changed: 56 additions & 17 deletions

File tree

module/openapi.go

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package module
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -237,10 +238,14 @@ func (m *OpenAPIModule) RegisterRoutes(router HTTPRouter) {
237238
// JSON endpoint: serve re-serialised spec as JSON.
238239
router.AddRoute(http.MethodGet, specPathJSON, &openAPISpecHandler{specJSON: m.specJSON})
239240

240-
// YAML endpoint: serve the original spec bytes with a YAML content-type.
241-
// This preserves the source format; if the original file was YAML it is
242-
// served as YAML, and if it was JSON it is served as-is (JSON is valid YAML).
243-
router.AddRoute(http.MethodGet, specPathYAML, &openAPIRawSpecHandler{specBytes: m.specBytes, contentType: "application/yaml"})
241+
// YAML endpoint: serve the original spec bytes with a content-type that
242+
// matches the source format. JSON source files are served as application/json;
243+
// YAML source files are served as application/yaml.
244+
rawContentType := "application/yaml"
245+
if trimmed := strings.TrimSpace(string(m.specBytes)); len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
246+
rawContentType = "application/json"
247+
}
248+
router.AddRoute(http.MethodGet, specPathYAML, &openAPIRawSpecHandler{specBytes: m.specBytes, contentType: rawContentType})
244249

245250
m.logger.Debug("OpenAPI spec endpoint registered", "module", m.name, "paths", []string{specPathJSON, specPathYAML})
246251
}
@@ -250,6 +255,8 @@ func (m *OpenAPIModule) RegisterRoutes(router HTTPRouter) {
250255
uiPath := m.cfg.SwaggerUI.Path
251256
if uiPath == "" {
252257
uiPath = "/docs"
258+
} else if !strings.HasPrefix(uiPath, "/") {
259+
uiPath = "/" + uiPath
253260
}
254261
uiRoutePath := basePath + uiPath
255262
specURL := basePath + "/openapi.json"
@@ -352,6 +359,10 @@ func (h *openAPIRouteHandler) validate(r *http.Request) []string {
352359
// application/octet-stream, but this engine is primarily used for JSON APIs and this
353360
// default simplifies client usage.
354361
mediaType = &mt
362+
} else if ct != "" && len(h.op.RequestBody.Content) > 0 {
363+
// Content-Type is present but not listed in the spec — reject with 400.
364+
errs = append(errs, fmt.Sprintf("unsupported Content-Type %q; spec defines: %s",
365+
ct, supportedContentTypes(h.op.RequestBody.Content)))
355366
}
356367

357368
// 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 {
365376
)
366377
errs = append(errs, "failed to read request body")
367378
} else {
368-
// Always restore body for downstream handlers.
369-
r.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
379+
// Always restore body for downstream handlers using the original byte slice
380+
// to avoid a bytes→string→bytes conversion that could corrupt non-UTF-8 payloads.
381+
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
370382

371383
if h.op.RequestBody.Required && len(bodyBytes) == 0 {
372384
errs = append(errs, "request body is required but missing")
373385
} else if mediaType != nil && mediaType.Schema != nil && len(bodyBytes) > 0 {
374386
var bodyData any
375-
if jsonErr := json.Unmarshal(bodyBytes, &bodyData); jsonErr == nil {
376-
if bodyErrs := validateJSONBody(bodyData, mediaType.Schema); len(bodyErrs) > 0 {
377-
errs = append(errs, bodyErrs...)
378-
}
387+
if jsonErr := json.Unmarshal(bodyBytes, &bodyData); jsonErr != nil {
388+
errs = append(errs, fmt.Sprintf("request body contains invalid JSON: %v", jsonErr))
389+
} else if bodyErrs := validateJSONBody(bodyData, mediaType.Schema); len(bodyErrs) > 0 {
390+
errs = append(errs, bodyErrs...)
379391
}
380392
}
381393
}
@@ -715,3 +727,13 @@ func swaggerUIHTML(title, specURL string) string {
715727
func htmlEscape(s string) string {
716728
return html.EscapeString(s)
717729
}
730+
731+
// supportedContentTypes returns a comma-joined list of content types defined
732+
// in the requestBody.content map, used in validation error messages.
733+
func supportedContentTypes(content map[string]openAPIMediaType) string {
734+
types := make([]string, 0, len(content))
735+
for ct := range content {
736+
types = append(types, ct)
737+
}
738+
return strings.Join(types, ", ")
739+
}

module/openapi_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,17 @@ func TestOpenAPIModule_RequestValidation_Body(t *testing.T) {
416416
t.Errorf("expected 400 validation error, got %d: %s", w.Code, w.Body.String())
417417
}
418418
})
419+
420+
t.Run("invalid JSON", func(t *testing.T) {
421+
body := `{"name": "Fluffy",` // malformed JSON
422+
w := httptest.NewRecorder()
423+
r := httptest.NewRequest(http.MethodPost, "/api/v1/pets", bytes.NewBufferString(body))
424+
r.Header.Set("Content-Type", "application/json")
425+
h.Handle(w, r)
426+
if w.Code != http.StatusBadRequest {
427+
t.Errorf("expected 400 validation error for malformed JSON, got %d: %s", w.Code, w.Body.String())
428+
}
429+
})
419430
}
420431

421432
func TestOpenAPIModule_NoValidation(t *testing.T) {

plugins/openapi/plugin.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package openapi
55

66
import (
7+
"log/slog"
8+
79
"github.com/CrisisTextLine/modular"
810
"github.com/GoCodeAlone/workflow/capability"
911
"github.com/GoCodeAlone/workflow/config"
@@ -56,7 +58,10 @@ func (p *Plugin) Capabilities() []capability.Contract {
5658
func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory {
5759
return map[string]plugin.ModuleFactory{
5860
"openapi": func(name string, cfg map[string]any) modular.Module {
59-
oacfg := module.OpenAPIConfig{}
61+
oacfg := module.OpenAPIConfig{
62+
// Default: enable request validation unless explicitly overridden.
63+
Validation: module.OpenAPIValidationConfig{Request: true},
64+
}
6065

6166
// NOTE: spec_file existence is not validated here at configuration time.
6267
// Path resolution is performed by ResolvePathInConfig (relative to the
@@ -227,7 +232,10 @@ func wireOpenAPIRoutes(app modular.Application, cfg *config.WorkflowConfig) erro
227232
}
228233

229234
if targetRouter == nil {
230-
// No router found — log and skip (not fatal; engine may be running without HTTP)
235+
// No router found — log a warning and skip (not fatal; engine may be running without HTTP).
236+
slog.Warn("openapi: no HTTP router found; skipping route registration",
237+
"module", oaMod.Name(),
238+
)
231239
continue
232240
}
233241

schema/module_schema.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -790,11 +790,9 @@ func (r *ModuleSchemaRegistry) registerBuiltins() {
790790
ConfigFields: []ConfigFieldDef{
791791
{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"},
792792
{Key: "base_path", Label: "Base Path", Type: FieldTypeString, Description: "Base path prefix for all generated routes", Placeholder: "/api/v1"},
793-
{Key: "router", Label: "Router Module", Type: FieldTypeString, Required: true, Description: "Name of the http.router module to register routes on", Placeholder: "my-router"},
794-
{Key: "validation.request", Label: "Validate Requests", Type: FieldTypeBool, DefaultValue: true, Description: "Enable request validation against the OpenAPI schema"},
795-
{Key: "validation.response", Label: "Validate Responses", Type: FieldTypeBool, DefaultValue: false, Description: "Enable response validation against the OpenAPI schema"},
796-
{Key: "swagger_ui.enabled", Label: "Enable Swagger UI", Type: FieldTypeBool, DefaultValue: false, Description: "Serve Swagger UI for interactive API documentation"},
797-
{Key: "swagger_ui.path", Label: "Swagger UI Path", Type: FieldTypeString, DefaultValue: "/docs", Description: "URL path for the Swagger UI", Placeholder: "/docs"},
793+
{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"},
794+
{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}"},
795+
{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\"}"},
798796
},
799797
DefaultConfig: map[string]any{"validation": map[string]any{"request": true, "response": false}, "swagger_ui": map[string]any{"enabled": false, "path": "/docs"}},
800798
})

0 commit comments

Comments
 (0)