From aa2aed1e04b02f13d0d1225ddc5a126e7e0eae73 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 23 Feb 2026 06:49:20 -0500 Subject: [PATCH 1/8] feat: add OpenAPI/Swagger spec module for auto-generating HTTP routes (#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 --- example/openapi-petstore.yaml | 33 ++ example/specs/petstore.yaml | 97 +++++ module/openapi.go | 642 ++++++++++++++++++++++++++++++++++ module/openapi_test.go | 561 +++++++++++++++++++++++++++++ plugins/openapi/plugin.go | 235 +++++++++++++ 5 files changed, 1568 insertions(+) create mode 100644 example/openapi-petstore.yaml create mode 100644 example/specs/petstore.yaml create mode 100644 module/openapi.go create mode 100644 module/openapi_test.go create mode 100644 plugins/openapi/plugin.go diff --git a/example/openapi-petstore.yaml b/example/openapi-petstore.yaml new file mode 100644 index 00000000..4eb330c8 --- /dev/null +++ b/example/openapi-petstore.yaml @@ -0,0 +1,33 @@ +requires: + plugins: + - name: workflow-plugin-http + - name: workflow-plugin-openapi + +modules: + # HTTP server on port 8095 + - name: petstore-server + type: http.server + config: + address: ":8095" + + # HTTP router — the OpenAPI module registers routes onto this + - name: petstore-router + type: http.router + dependsOn: + - petstore-server + + # OpenAPI module — parses the spec and generates routes + - name: pet-store-api + type: openapi + dependsOn: + - petstore-router + config: + spec_file: example/specs/petstore.yaml + base_path: /api/v1 + router: petstore-router + validation: + request: true + response: false + swagger_ui: + enabled: true + path: /docs diff --git a/example/specs/petstore.yaml b/example/specs/petstore.yaml new file mode 100644 index 00000000..b16b9973 --- /dev/null +++ b/example/specs/petstore.yaml @@ -0,0 +1,97 @@ +openapi: "3.0.0" +info: + title: Petstore API + version: "1.0.0" + description: A sample pet store API based on the OpenAPI 3.0 Petstore example + +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 + "400": + description: Invalid request + post: + operationId: createPet + summary: Create a pet + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + maxLength: 100 + tag: + type: string + maxLength: 50 + responses: + "201": + description: Null response + "400": + description: Validation error + + /pets/{petId}: + get: + operationId: showPetById + summary: Info for a specific pet + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: Expected response to a valid request + "404": + description: Pet not found + + /pets/{petId}/status: + put: + operationId: updatePetStatus + summary: Update pet status + parameters: + - name: petId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - status + properties: + status: + type: string + enum: + - available + - pending + - sold + responses: + "200": + description: Updated + "400": + description: Invalid status + "404": + description: Pet not found diff --git a/module/openapi.go b/module/openapi.go new file mode 100644 index 00000000..8759ee19 --- /dev/null +++ b/module/openapi.go @@ -0,0 +1,642 @@ +package module + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "regexp" + "strconv" + "strings" + + "github.com/CrisisTextLine/modular" + "gopkg.in/yaml.v3" +) + +// OpenAPIValidationConfig controls which request/response parts are validated. +type OpenAPIValidationConfig struct { + Request bool `yaml:"request" json:"request"` + Response bool `yaml:"response" json:"response"` +} + +// OpenAPISwaggerUIConfig controls Swagger UI hosting. +type OpenAPISwaggerUIConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Path string `yaml:"path" json:"path"` +} + +// OpenAPIConfig holds the full configuration for an OpenAPI module. +type OpenAPIConfig struct { + SpecFile string `yaml:"spec_file" json:"spec_file"` + BasePath string `yaml:"base_path" json:"base_path"` + Validation OpenAPIValidationConfig `yaml:"validation" json:"validation"` + SwaggerUI OpenAPISwaggerUIConfig `yaml:"swagger_ui" json:"swagger_ui"` + RouterName string `yaml:"router" json:"router"` // optional: explicit router to attach to +} + +// ---- Minimal OpenAPI v3 structs (parsed from YAML/JSON) ---- + +// openAPISpec is a minimal representation of an OpenAPI 3.x specification. +type openAPISpec struct { + OpenAPI string `yaml:"openapi" json:"openapi"` + Info openAPIInfo `yaml:"info" json:"info"` + Paths map[string]openAPIPathItem `yaml:"paths" json:"paths"` +} + +type openAPIInfo struct { + Title string `yaml:"title" json:"title"` + Version string `yaml:"version" json:"version"` +} + +// openAPIPathItem maps HTTP methods to operation objects. +type openAPIPathItem map[string]*openAPIOperation + +// openAPIOperation holds the metadata for a single operation. +type openAPIOperation struct { + OperationID string `yaml:"operationId" json:"operationId"` + Summary string `yaml:"summary" json:"summary"` + Parameters []openAPIParameter `yaml:"parameters" json:"parameters"` + RequestBody *openAPIRequestBody `yaml:"requestBody" json:"requestBody"` + Responses map[string]openAPIResponse `yaml:"responses" json:"responses"` +} + +// openAPIParameter describes a path, query, header, or cookie parameter. +type openAPIParameter struct { + Name string `yaml:"name" json:"name"` + In string `yaml:"in" json:"in"` // path | query | header | cookie + Required bool `yaml:"required" json:"required"` + Schema *openAPISchema `yaml:"schema" json:"schema"` +} + +// openAPIRequestBody describes the request body for an operation. +type openAPIRequestBody struct { + Required bool `yaml:"required" json:"required"` + Content map[string]openAPIMediaType `yaml:"content" json:"content"` +} + +// openAPIMediaType holds a schema for a content type entry. +type openAPIMediaType struct { + Schema *openAPISchema `yaml:"schema" json:"schema"` +} + +// openAPIResponse describes a single response entry. +type openAPIResponse struct { + Description string `yaml:"description" json:"description"` +} + +// 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"` +} + +// ---- OpenAPIModule ---- + +// OpenAPIModule parses an OpenAPI v3 spec and registers HTTP routes that +// validate incoming requests against the spec schemas. +type OpenAPIModule struct { + name string + cfg OpenAPIConfig + spec *openAPISpec + specBytes []byte // raw spec bytes for serving + routerName string + logger *slog.Logger +} + +// NewOpenAPIModule creates a new OpenAPIModule with the given name and config. +func NewOpenAPIModule(name string, cfg OpenAPIConfig) *OpenAPIModule { + return &OpenAPIModule{ + name: name, + cfg: cfg, + routerName: cfg.RouterName, + logger: slog.Default(), + } +} + +// Name returns the module name. +func (m *OpenAPIModule) Name() string { return m.name } + +// Init loads and parses the spec file. +func (m *OpenAPIModule) Init(app modular.Application) error { + if app != nil { + if logger := app.Logger(); logger != nil { + if sl, ok := logger.(*slog.Logger); ok { + m.logger = sl + } + } + } + + if m.cfg.SpecFile == "" { + return fmt.Errorf("openapi module %q: spec_file is required", m.name) + } + + data, err := os.ReadFile(m.cfg.SpecFile) //nolint:gosec // path comes from operator config + if err != nil { + return fmt.Errorf("openapi module %q: reading spec file %q: %w", m.name, m.cfg.SpecFile, err) + } + m.specBytes = data + + spec, err := parseOpenAPISpec(data) + if err != nil { + return fmt.Errorf("openapi module %q: parsing spec: %w", m.name, err) + } + m.spec = spec + m.logger.Info("OpenAPI spec loaded", + "module", m.name, + "title", spec.Info.Title, + "version", spec.Info.Version, + "paths", len(spec.Paths), + ) + return nil +} + +// Dependencies returns nil; routing is wired via ProvidesServices / Init wiring hooks. +func (m *OpenAPIModule) Dependencies() []string { return nil } + +// Start is a no-op; routes are registered during wiring. +func (m *OpenAPIModule) Start(_ context.Context) error { return nil } + +// Stop is a no-op. +func (m *OpenAPIModule) Stop(_ context.Context) error { return nil } + +// ProvidesServices exposes this module as an OpenAPIModule service so wiring +// hooks can find it and register its routes on an HTTP router. +func (m *OpenAPIModule) ProvidesServices() []modular.ServiceProvider { + return []modular.ServiceProvider{ + { + Name: m.name, + Description: "OpenAPI spec router module", + Instance: m, + }, + } +} + +// RequiresServices returns nil; router dependency is resolved via wiring hooks. +func (m *OpenAPIModule) RequiresServices() []modular.ServiceDependency { return nil } + +// RouterName returns the optional explicit router module name to attach routes to. +func (m *OpenAPIModule) RouterName() string { return m.routerName } + +// RegisterRoutes attaches all spec paths (and optional Swagger UI / spec endpoints) +// to the given HTTPRouter. +func (m *OpenAPIModule) RegisterRoutes(router HTTPRouter) { + if m.spec == nil { + m.logger.Warn("OpenAPI spec not loaded; skipping route registration", "module", m.name) + return + } + + basePath := strings.TrimRight(m.cfg.BasePath, "/") + + // Register a route for each path+method in the spec + for specPath, pathItem := range m.spec.Paths { + for method, op := range pathItem { + httpMethod := strings.ToUpper(method) + if !isValidHTTPMethod(httpMethod) { + continue + } + routePath := basePath + openAPIPathToHTTPPath(specPath) + handler := m.buildRouteHandler(specPath, httpMethod, op) + router.AddRoute(httpMethod, routePath, handler) + m.logger.Debug("OpenAPI route registered", + "module", m.name, + "method", httpMethod, + "path", routePath, + "operationId", op.OperationID, + ) + } + } + + // Serve raw spec at /openapi.json and /openapi.yaml + if len(m.specBytes) > 0 { + specPathJSON := basePath + "/openapi.json" + specPathYAML := basePath + "/openapi.yaml" + specHandler := m.buildSpecHandler() + router.AddRoute(http.MethodGet, specPathJSON, specHandler) + router.AddRoute(http.MethodGet, specPathYAML, specHandler) + m.logger.Debug("OpenAPI spec endpoint registered", "module", m.name, "paths", []string{specPathJSON, specPathYAML}) + } + + // Serve Swagger UI + if m.cfg.SwaggerUI.Enabled { + uiPath := m.cfg.SwaggerUI.Path + if uiPath == "" { + uiPath = "/docs" + } + uiRoutePath := basePath + uiPath + specURL := basePath + "/openapi.json" + uiHandler := m.buildSwaggerUIHandler(specURL) + router.AddRoute(http.MethodGet, uiRoutePath, uiHandler) + m.logger.Info("Swagger UI registered", "module", m.name, "path", uiRoutePath, "spec", specURL) + } +} + +// ---- Handler builders ---- + +// buildRouteHandler creates an HTTPHandler that validates the request (if enabled) +// and returns a 501 Not Implemented stub response. In a full integration the +// caller would wrap this handler or replace the stub with real business logic. +func (m *OpenAPIModule) buildRouteHandler(specPath, method string, op *openAPIOperation) HTTPHandler { + validateReq := m.cfg.Validation.Request + return &openAPIRouteHandler{ + module: m, + specPath: specPath, + method: method, + op: op, + validateReq: validateReq, + } +} + +// buildSpecHandler serves the raw spec bytes as JSON (re-serialised from the +// parsed spec) so consumers always get valid JSON regardless of whether the +// original file was YAML. +func (m *OpenAPIModule) buildSpecHandler() HTTPHandler { + specJSON, err := json.Marshal(m.spec) + if err != nil { + specJSON = m.specBytes // fallback to raw bytes + } + return &openAPISpecHandler{specJSON: specJSON} +} + +// buildSwaggerUIHandler returns an inline Swagger UI page that loads the spec +// from specURL. This avoids any asset bundling — a CDN-hosted swagger-ui is used. +func (m *OpenAPIModule) buildSwaggerUIHandler(specURL string) HTTPHandler { + html := swaggerUIHTML(m.spec.Info.Title, specURL) + return &openAPISwaggerUIHandler{html: html} +} + +// ---- openAPIRouteHandler ---- + +type openAPIRouteHandler struct { + module *OpenAPIModule + specPath string + method string + op *openAPIOperation + validateReq bool +} + +func (h *openAPIRouteHandler) Handle(w http.ResponseWriter, r *http.Request) { + if h.validateReq { + if errs := h.validate(r); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "request validation failed", + "errors": errs, + }) + return + } + } + + // Default stub: 501 Not Implemented + // In a full integration callers wire their own handler on top of this module. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "not implemented", + "operationId": h.op.OperationID, + "path": h.specPath, + "method": h.method, + }) +} + +// validate checks required parameters and request body against the spec. +func (h *openAPIRouteHandler) validate(r *http.Request) []string { + var errs []string + + // Validate parameters + for _, p := range h.op.Parameters { + val := extractParam(r, p) + if p.Required && val == "" { + errs = append(errs, fmt.Sprintf("required parameter %q (in %s) is missing", p.Name, p.In)) + continue + } + if val != "" && p.Schema != nil { + if schemaErrs := validateScalarValue(val, p.Name, p.Schema); len(schemaErrs) > 0 { + errs = append(errs, schemaErrs...) + } + } + } + + // Validate request body + if h.op.RequestBody != nil { + ct := r.Header.Get("Content-Type") + // Normalise content-type (strip params like "; charset=utf-8") + if idx := strings.Index(ct, ";"); idx >= 0 { + ct = strings.TrimSpace(ct[:idx]) + } + + 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 == "" { + // Default to application/json when no Content-Type is sent + mediaType = &mt + } + + if h.op.RequestBody.Required && r.ContentLength == 0 && r.Body == http.NoBody { + errs = append(errs, "request body is required but missing") + } else if mediaType != nil && mediaType.Schema != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err == 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...) + } + } + // Restore body for downstream handlers + r.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + } + } + } + + return errs +} + +// ---- openAPISpecHandler ---- + +type openAPISpecHandler struct { + specJSON []byte +} + +func (h *openAPISpecHandler) Handle(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(h.specJSON) //nolint:gosec // G705: spec JSON is loaded from a trusted config file, not user input +} + +// ---- openAPISwaggerUIHandler ---- + +type openAPISwaggerUIHandler struct { + html string +} + +func (h *openAPISwaggerUIHandler) Handle(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(h.html)) //nolint:gosec // G705: HTML is generated from a trusted template, not user input +} + +// ---- Helpers ---- + +// parseOpenAPISpec parses a YAML or JSON byte slice into an openAPISpec. +func parseOpenAPISpec(data []byte) (*openAPISpec, error) { + var spec openAPISpec + // Try YAML first (which also handles JSON since JSON is valid YAML) + if err := yaml.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("yaml parse: %w", err) + } + if spec.OpenAPI == "" { + // May be JSON that yaml couldn't decode properly; try JSON directly + if err := json.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("neither yaml nor json parse succeeded: %w", err) + } + } + return &spec, nil +} + +// openAPIPathToHTTPPath converts OpenAPI path templates ({param}) to Go 1.22+ +// ServeMux patterns ({param}). For older mux implementations the braces are +// kept since most custom routers accept the same syntax. +func openAPIPathToHTTPPath(specPath string) string { + // OpenAPI uses {param}; Go 1.22 net/http.ServeMux uses {param} too. + // No transformation needed — return as-is. + return specPath +} + +// isValidHTTPMethod returns true for standard HTTP verbs (OpenAPI supports a +// defined subset: get, put, post, delete, options, head, patch, trace). +func isValidHTTPMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodPut, http.MethodPost, + http.MethodDelete, http.MethodOptions, http.MethodHead, + http.MethodPatch, "TRACE": + return true + } + return false +} + +// extractParam extracts a parameter value from the request based on its location. +func extractParam(r *http.Request, p openAPIParameter) string { + switch p.In { + case "query": + return r.URL.Query().Get(p.Name) + case "header": + return r.Header.Get(p.Name) + case "path": + // Go 1.22 net/http.ServeMux populates path values via r.PathValue + return r.PathValue(p.Name) + case "cookie": + if c, err := r.Cookie(p.Name); err == nil { + return c.Value + } + } + return "" +} + +// validateScalarValue validates a string value against a schema (type/format/enum checks). +func validateScalarValue(val, name string, schema *openAPISchema) []string { + var errs []string + switch schema.Type { + case "integer": + n, err := strconv.ParseInt(val, 10, 64) + if err != nil { + errs = append(errs, fmt.Sprintf("parameter %q must be an integer, got %q", name, val)) + return errs + } + if schema.Minimum != nil && float64(n) < *schema.Minimum { + errs = append(errs, fmt.Sprintf("parameter %q must be >= %v", name, *schema.Minimum)) + } + if schema.Maximum != nil && float64(n) > *schema.Maximum { + errs = append(errs, fmt.Sprintf("parameter %q must be <= %v", name, *schema.Maximum)) + } + case "number": + f, err := strconv.ParseFloat(val, 64) + if err != nil { + errs = append(errs, fmt.Sprintf("parameter %q must be a number, got %q", name, val)) + return errs + } + if schema.Minimum != nil && f < *schema.Minimum { + errs = append(errs, fmt.Sprintf("parameter %q must be >= %v", name, *schema.Minimum)) + } + if schema.Maximum != nil && f > *schema.Maximum { + errs = append(errs, fmt.Sprintf("parameter %q must be <= %v", name, *schema.Maximum)) + } + case "boolean": + if val != "true" && val != "false" { + errs = append(errs, fmt.Sprintf("parameter %q must be 'true' or 'false', got %q", name, val)) + } + case "string": + if schema.MinLength != nil && len(val) < *schema.MinLength { + errs = append(errs, fmt.Sprintf("parameter %q must have minLength %d", name, *schema.MinLength)) + } + if schema.MaxLength != nil && len(val) > *schema.MaxLength { + errs = append(errs, fmt.Sprintf("parameter %q must have maxLength %d", name, *schema.MaxLength)) + } + if schema.Pattern != "" { + if ok, _ := regexp.MatchString(schema.Pattern, val); !ok { + errs = append(errs, fmt.Sprintf("parameter %q does not match pattern %q", name, schema.Pattern)) + } + } + } + // Enum validation + if len(schema.Enum) > 0 { + found := false + for _, e := range schema.Enum { + if fmt.Sprintf("%v", e) == val { + found = true + break + } + } + if !found { + errs = append(errs, fmt.Sprintf("parameter %q must be one of %v", name, schema.Enum)) + } + } + return errs +} + +// validateJSONBody validates a decoded JSON body against an object schema. +func validateJSONBody(body any, schema *openAPISchema) []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 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)) + } + } + // Validate individual properties + for field, propSchema := range schema.Properties { + val, present := obj[field] + if !present { + continue + } + if fieldErrs := validateJSONValue(val, field, propSchema); len(fieldErrs) > 0 { + errs = append(errs, fieldErrs...) + } + } + return errs +} + +// 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)) + } + } + case "integer": + f, ok := val.(float64) + if !ok { + return []string{fmt.Sprintf("field %q must be an integer", name)} + } + if schema.Minimum != nil && f < *schema.Minimum { + errs = append(errs, fmt.Sprintf("field %q must be >= %v", name, *schema.Minimum)) + } + if schema.Maximum != nil && f > *schema.Maximum { + errs = append(errs, fmt.Sprintf("field %q must be <= %v", name, *schema.Maximum)) + } + case "number": + f, ok := val.(float64) + if !ok { + return []string{fmt.Sprintf("field %q must be a number", name)} + } + if schema.Minimum != nil && f < *schema.Minimum { + errs = append(errs, fmt.Sprintf("field %q must be >= %v", name, *schema.Minimum)) + } + if schema.Maximum != nil && f > *schema.Maximum { + errs = append(errs, fmt.Sprintf("field %q must be <= %v", name, *schema.Maximum)) + } + case "boolean": + if _, ok := val.(bool); !ok { + errs = append(errs, fmt.Sprintf("field %q must be a boolean", name)) + } + case "object": + if subErrs := validateJSONBody(val, schema); len(subErrs) > 0 { + errs = append(errs, subErrs...) + } + } + // Enum validation + if len(schema.Enum) > 0 { + found := false + for _, e := range schema.Enum { + if fmt.Sprintf("%v", e) == fmt.Sprintf("%v", val) { + found = true + break + } + } + if !found { + errs = append(errs, fmt.Sprintf("field %q must be one of %v", name, schema.Enum)) + } + } + return errs +} + +// swaggerUIHTML returns a minimal, self-contained Swagger UI HTML page that +// loads the spec from specURL using the official Swagger UI CDN bundle. +func swaggerUIHTML(title, specURL string) string { + if title == "" { + title = "API Documentation" + } + return ` + + + + + ` + htmlEscape(title) + ` + + + +
+ + + +` +} + +// htmlEscape escapes a string for safe embedding in HTML attributes/text. +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "'", "'") + return s +} diff --git a/module/openapi_test.go b/module/openapi_test.go new file mode 100644 index 00000000..92896f06 --- /dev/null +++ b/module/openapi_test.go @@ -0,0 +1,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(``) + if strings.Contains(got, " +