diff --git a/.golangci.yaml b/.golangci.yaml index cf230d8..64a1f33 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,245 +1,18 @@ -# This file contains all available configuration options -# with their default values. +version: "2" -# options for analysis running run: - # default concurrency is a available CPU number - # concurrency: 4 - - # timeout for analysis, e.g. 30s, 5m, default is 1m timeout: 30m - - # exit code when at least one issue was found, default is 1 - issues-exit-code: 1 - - # include test files or not, default is true tests: true - # list of build tags, all linters use it. Default is empty list. - # build-tags: - # - mytag - - # which dirs to skip: issues from them won't be reported; - # can use regexp here: generated.*, regexp is applied on full path; - # default value is empty list, but default dirs are skipped independently - # from this option's value (see skip-dirs-use-default). - # skip-dirs: - # - src/external_libs - # - autogenerated_by_my_lib - - # default is true. Enables skipping of directories: - # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ - skip-dirs-use-default: true - - # which files to skip: they will be analyzed, but issues from them - # won't be reported. Default value is empty list, but there is - # no need to include all autogenerated files, we confidently recognize - # autogenerated files. If it's not please let us know. - # skip-files: - # - ".*\\.my\\.go$" - # - lib/bad.go - - # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": - # If invoked with -mod=readonly, the go command is disallowed from the implicit - # automatic updating of go.mod described above. Instead, it fails when any changes - # to go.mod are needed. This setting is most useful to check that go.mod does - # not need updates, such as in a continuous integration and testing system. - # If invoked with -mod=vendor, the go command assumes that the vendor - # directory holds the correct copies of dependencies and ignores - # the dependency descriptions in go.mod. - # modules-download-mode: readonly|release|vendor - -# output configuration options output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" - format: line-number - - # print lines of code with issue, default is true - print-issued-lines: true - - # print linter name in the end of issue text, default is true - print-linter-name: true - -# all available settings of specific linters -linters-settings: - errcheck: - # report about not checking of errors in type assetions: `a := b.(MyStruct)`; - # default is false: such cases aren't reported by default. - check-type-assertions: false - - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; - # default is false: such cases aren't reported by default. - check-blank: false - - # [deprecated] comma-separated list of pairs of the form pkg:regex - # the regex is used to ignore names within pkg. (default "fmt:.*"). - # see https://github.com/kisielk/errcheck#the-deprecated-method for details - # ignore: fmt:.*,io/ioutil:^Read.* - - # path to a file containing a list of functions to exclude from checking - # see https://github.com/kisielk/errcheck#excluding-functions for details - # exclude: /path/to/file.txt - - # Disable error checking, as errorcheck detects more errors and is more configurable. - gosec: - exclude: - - "G104" - - funlen: - lines: 60 - statements: 40 - - govet: - # report about shadowed variables - check-shadowing: true - - # settings per analyzer - settings: - printf: # analyzer name, run `go tool vet help` to see all analyzers - funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf - - # enable or disable analyzers by name - # enable: - # - atomicalign - # enable-all: false - # disable: - # - shadow - # disable-all: false - golint: - # minimal confidence for issues, default is 0.8 - min-confidence: 0.8 - gofmt: - # simplify code: gofmt with `-s` option, true by default - simplify: true - goimports: - # put imports beginning with prefix after 3rd-party packages; - # it's a comma-separated list of prefixes - # local-prefixes: github.com/org/project - gocyclo: - # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 30 - gocognit: - # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 20 - maligned: - # print struct with more effective memory layout or not, false by default - suggest-new: true - dupl: - # tokens count to trigger issue, 150 by default - threshold: 100 - goconst: - # minimal length of string constant, 3 by default - min-len: 3 - # minimal occurrences count to trigger, 3 by default - min-occurrences: 3 - # depguard: - # list-type: blacklist - # include-go-root: false - # packages: - # - github.com/sirupsen/logrus - # packages-with-error-messages: - # # specify an error message to output when a blacklisted package is used - # github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" - misspell: - # Correct spellings using locale preferences for US or UK. - # Default is to use a neutral variety of English. - # Setting locale to US will correct the British spelling of 'colour' to 'color'. - locale: US - ignore-words: - - GitLab - lll: - # max line length, lines longer will be reported. Default is 120. - # '\t' is counted as 1 character by default, and can be changed with the tab-width option - line-length: 120 - # tab width in spaces. Default to 1. - tab-width: 1 - unused: - # treat code as a program (not a library) and report unused exported identifiers; default is false. - # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: - # if it's called for subdir of a project it can't find funcs usages. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - unparam: - # Inspect exported functions, default is false. Set to true if no external program/library imports your code. - # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: - # if it's called for subdir of a project it can't find external interfaces. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - nakedret: - # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 - max-func-lines: 30 - prealloc: - # XXX: we don't recommend using this linter before doing performance profiling. - # For most programs usage of prealloc will be a premature optimization. - - # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. - # True by default. - simple: true - range-loops: true # Report preallocation suggestions on range loops, true by default - for-loops: false # Report preallocation suggestions on for loops, false by default - gocritic: - # Which checks should be enabled; can't be combined with 'disabled-checks'; - # See https://go-critic.github.io/overview#checks-overview - # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` - # By default list of stable checks is used. - # enabled-checks: - # - rangeValCopy - - # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty - # disabled-checks: - # - regexpMust - - # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. - # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". - # enabled-tags: - # - performance - - settings: # settings passed to gocritic - captLocal: # must be valid enabled check name - paramsOnly: true - # rangeValCopy: - # sizeThreshold: 32 - godox: - # report any comments starting with keywords, this is useful for TODO or FIXME comments that - # might be left in the code accidentally and should be resolved before merging - keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting - - TODO - - BUG - - FIXME - - NOTE - - OPTIMIZE # marks code that should be optimized before merging - - HACK # marks hack-arounds that should be removed before merging - dogsled: - # checks assignments with too many blank identifiers; default is 2 - max-blank-identifiers: 2 - - whitespace: - multi-if: false # Enforces newlines (or comments) after every multi-line if statement - multi-func: false # Enforces newlines (or comments) after every multi-line function signature - wsl: - # If true append is only allowed to be cuddled if appending value is - # matching variables, fields or types on line above. Default is true. - strict-append: true - # Allow calls and assignments to be cuddled as long as the lines have any - # matching variables, fields or types. Default is true. - allow-assign-and-call: true - # Allow multiline assignments to be cuddled. Default is true. - allow-multiline-assign: true - # Allow declarations (var) to be cuddled. - allow-cuddle-declarations: false - # Allow trailing comments in ending of blocks - allow-trailing-comment: false - # Force newlines in end of case at this limit (0 = never). - force-case-trailing-whitespace: 0 + formats: + text: + path: stdout + print-issued-lines: true + print-linter-name: true linters: - # please, do not use `enable-all`: it's deprecated and will be removed soon. - # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint - disable-all: true + default: none enable: - bodyclose - depguard @@ -251,94 +24,118 @@ linters: - goconst - gocritic - godox - - gofmt - - goimports - - golint - gosec - - gosimple - govet - ineffassign - - interfacer - misspell - nakedret - - scopelint - staticcheck - - structcheck - - stylecheck - - typecheck - unconvert - unparam - unused - whitespace - # don't enable: - # - deadcode - # - gochecknoglobals - # - gochecknoinits - # - gocyclo - # - lll - # - maligned - # - prealloc - # - varcheck + - wsl_v5 + settings: + errcheck: + check-type-assertions: false + check-blank: false + + gosec: + excludes: + - "G104" + + funlen: + lines: 60 + statements: 40 + + govet: + enable: + - shadow + + gocognit: + min-complexity: 20 + + dupl: + threshold: 100 + + goconst: + min-len: 3 + min-occurrences: 3 + + misspell: + locale: US + ignore-rules: + - gitlab + + unparam: + check-exported: false + + nakedret: + max-func-lines: 30 + + gocritic: + settings: + captLocal: + paramsOnly: true + + godox: + keywords: + - TODO + - BUG + - FIXME + - NOTE + - OPTIMIZE + - HACK + + dogsled: + max-blank-identifiers: 2 + + depguard: + rules: + main: + list-mode: lax + allow: + - $gostd + - github.com/getkin/kin-openapi + - github.com/gin-gonic/gin + - github.com/sirupsen/logrus + - github.com/stretchr/testify + - github.com/phumberdroz/gin-openapi-validator + + whitespace: + multi-if: false + multi-func: false + + wsl_v5: + allow-first-in-block: true + allow-whole-block: true + case-max-lines: 0 + default: all + disable: + - assign-expr + + exclusions: + rules: + - path: _test\.go + linters: + - gocyclo + - errcheck + - dupl + - gosec + - funlen + - linters: + - lll + source: "^//go:generate " + +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: true issues: - # List of regexps of issue texts to exclude, empty list by default. - # But independently from this option we use default exclude patterns, - # it can be disabled by `exclude-use-default: false`. To list all - # excluded by default patterns execute `golangci-lint run --help` - # exclude: - # - abcdef - - # Excluding configuration per-path, per-linter, per-text and per-source - exclude-rules: - # Exclude some linters from running on tests files. - - path: _test\.go - linters: - - gocyclo - - errcheck - - dupl - - gosec - - funlen - - # Exclude known linters from partially hard-vendored code, - # which is impossible to exclude via "nolint" comments. - # - path: internal/hmac/ - # text: "weak cryptographic primitive" - # linters: - # - gosec - - # Exclude some staticcheck messages - # - linters: - # - staticcheck - # text: "SA9003:" - - # Exclude lll issues for long lines with go:generate - - linters: - - lll - source: "^//go:generate " - - # Independently from option `exclude` we use default exclude patterns, - # it can be disabled by this option. To list all - # excluded by default patterns execute `golangci-lint run --help`. - # Default value for this option is true. - exclude-use-default: false - - # Maximum issues count per one linter. Set to 0 to disable. Default is 50. max-issues-per-linter: 0 - - # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. max-same-issues: 0 - - # Show only new issues: if there are unstaged changes or untracked files, - # only those changes are analyzed, else only changes in HEAD~ are analyzed. - # It's a super-useful option for integration of golangci-lint into existing - # large codebase. It's not practical to fix all existing issues at the moment - # of integration: much better don't allow issues in new code. - # Default is false. new: false - - # Show only new issues created after git revision `REV` - # This should be passed as flag during individual CI jobs. - # new-from-rev: REV - - # Show only new issues created in git patch with set file path. - # new-from-patch: path/to/patch/file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a2365f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `pkg/gin-openapi-validator/` contains the public library code and its tests. + - Core middleware and error handling live in `validator.go` and `validationerror.go`. + - Package docs are in `doc.go`. + - Test fixtures like `petstore.yaml` are stored alongside tests and embedded via `go:embed`. +- There is no `cmd/` directory; this repo is a library module (`go.mod` defines the module path). + +## Build, Test, and Development Commands +- `go test ./...` runs the full test suite across the module. +- `go test ./pkg/gin-openapi-validator -run TestBadRequests` runs a focused test by name. +- `go test -v ./pkg/gin-openapi-validator` runs verbose tests for the main package. +- `gofmt -w pkg/` formats all Go sources in the package. + +## Coding Style & Naming Conventions +- Use Go standard formatting (tabs, gofmt). Run `gofmt` on changed files. +- Package name is `ginopenapivalidator` (no underscores); new files should use the same package. +- Exported identifiers should be PascalCase; unexported helpers should be lowerCamelCase. + +## Testing Guidelines +- Tests use Go’s `testing` package with `testify` assertions (`assert`, `require`). +- Test files follow the `*_test.go` convention and live next to the code they test. +- Keep new tests table-driven where it improves coverage (see `TestBadRequests`). +- Run `go test ./...` before submitting changes. + +## Commit & Pull Request Guidelines +- Git history is minimal and uses short, sentence-case messages (e.g., “Initial commit”). + - Keep commit subjects short and descriptive; there is no strict convention enforced. +- PRs should include: + - A brief description of the behavior change. + - Any relevant test commands and results (e.g., `go test ./...`). + - Sample requests/responses if the middleware behavior changes. + +## Security & Configuration Tips +- The validator loads OpenAPI specs from bytes; avoid committing sensitive API specs. +- If you add new validation options, keep defaults safe and document any behavior changes. diff --git a/README.md b/README.md index 0587a1a..fd5055d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,68 @@ # gin-openapi-validator -## Usage +OpenAPI request/response validation middleware for the Gin web framework. + +This package plugs into Gin and validates incoming requests and outgoing responses against an OpenAPI 3 specification using `kin-openapi`. It is designed to be lightweight and easy to integrate in existing services. + +## Features +- Validate requests (path params, query params, headers, and body). +- Validate responses and log validation errors. +- Optionally fail invalid responses in strict mode. +- Supports custom string formats (for example UUID RFC 4122). +- Simple middleware API for Gin. + +## Install +```bash +go get github.com/phumberdroz/gin-openapi-validator +``` +## Usage ```go -import "github.com/phumberdroz/gin-openapi-validator" +package main + +import ( + _ "embed" + + "github.com/gin-gonic/gin" + ginopenapivalidator "github.com/phumberdroz/gin-openapi-validator/pkg/gin-openapi-validator" +) + +//go:embed api/openapi.yaml +var spec []byte -// Basic (logs violations only) -r.Use(ginopenapivalidator.Validator(openapiYAMLBytes)) +func main() { + r := gin.Default() -// Strict mode: return 500 when response is invalid -r.Use(ginopenapivalidator.Validator(openapiYAMLBytes, ginopenapivalidator.ValidatorOptions{ - StrictResponse: true, -})) + // Basic mode: logs response validation failures only. + r.Use(ginopenapivalidator.Validator(spec)) + + // Strict mode: invalid responses return 500. + r.Use(ginopenapivalidator.Validator(spec, ginopenapivalidator.ValidatorOptions{ + StrictResponse: true, + })) + + r.GET("/pets", func(c *gin.Context) { + c.JSON(200, []gin.H{{"name": "string", "tag": "string", "id": 1}}) + }) + + r.Run(":8080") +} ``` + +## Project Structure +- `pkg/gin-openapi-validator/`: library source and tests +- `pkg/gin-openapi-validator/petstore.yaml`: test OpenAPI fixture + +## Testing +```bash +go test ./... +``` + +## Versioning +This project does not currently publish releases. Consumers should vendor or pin the module version in `go.mod`. + +## Contributing +See `AGENTS.md` for contributor guidelines and local development tips. + +## License +See `LICENSE` for details. diff --git a/pkg/gin-openapi-validator/validationerror.go b/pkg/gin-openapi-validator/validationerror.go index cb5bfe6..ba7758a 100644 --- a/pkg/gin-openapi-validator/validationerror.go +++ b/pkg/gin-openapi-validator/validationerror.go @@ -14,7 +14,9 @@ import ( "github.com/getkin/kin-openapi/routers" ) -// Decode takes a Validation error and decodes back to a *openapi3filter.ValidationError +const unsupportedContentTypeReason = "header 'Content-Type' has unexpected value: " + +// Decode takes a validation error and decodes it back to an *openapi3filter.ValidationError. func Decode(err error) (*openapi3filter.ValidationError, error) { var cErr *openapi3filter.ValidationError if err.Error() == "invalid route" { @@ -24,6 +26,7 @@ func Decode(err error) (*openapi3filter.ValidationError, error) { } return cErr, nil } + if e, ok := err.(*routers.RouteError); ok { cErr = convertRouteError(e) return cErr, nil @@ -47,65 +50,57 @@ func Decode(err error) (*openapi3filter.ValidationError, error) { if cErr != nil { return cErr, nil } + return nil, err } func convertRouteError(e *routers.RouteError) *openapi3filter.ValidationError { - var cErr *openapi3filter.ValidationError - - //errors.As() switch e.Reason { case "Path doesn't support the HTTP method": - cErr = &openapi3filter.ValidationError{Status: http.StatusMethodNotAllowed, Title: e.Reason} + return &openapi3filter.ValidationError{Status: http.StatusMethodNotAllowed, Title: e.Reason} default: - cErr = &openapi3filter.ValidationError{Status: http.StatusNotFound, Title: e.Reason} + return &openapi3filter.ValidationError{Status: http.StatusNotFound, Title: e.Reason} } - return cErr } func convertBasicRequestError(e *openapi3filter.RequestError) *openapi3filter.ValidationError { - var cErr *openapi3filter.ValidationError - unsupportedContentType := "header 'Content-Type' has unexpected value: " - if strings.HasPrefix(e.Reason, unsupportedContentType) { + if strings.HasPrefix(e.Reason, unsupportedContentTypeReason) { if strings.HasSuffix(e.Reason, `: ""`) { - cErr = &openapi3filter.ValidationError{ + return &openapi3filter.ValidationError{ Status: http.StatusUnsupportedMediaType, Title: "header 'Content-Type' is required", } - } else { - cErr = &openapi3filter.ValidationError{ - Status: http.StatusUnsupportedMediaType, - Title: "unsupported content type " + strings.TrimPrefix(e.Reason, unsupportedContentType), - } } - } else { - cErr = &openapi3filter.ValidationError{ - Status: http.StatusBadRequest, - Title: e.Error(), + + return &openapi3filter.ValidationError{ + Status: http.StatusUnsupportedMediaType, + Title: "unsupported content type " + strings.TrimPrefix(e.Reason, unsupportedContentTypeReason), } } - return cErr + + return &openapi3filter.ValidationError{ + Status: http.StatusBadRequest, + Title: e.Error(), + } } func convertErrInvalidRequired(e *openapi3filter.RequestError) *openapi3filter.ValidationError { - var cErr *openapi3filter.ValidationError if e.Reason == openapi3filter.ErrInvalidRequired.Error() && e.Parameter != nil { - cErr = &openapi3filter.ValidationError{ + return &openapi3filter.ValidationError{ Status: http.StatusBadRequest, Title: fmt.Sprintf("Parameter '%s' in %s is required", e.Parameter.Name, e.Parameter.In), } - } else { - cErr = &openapi3filter.ValidationError{ - Status: http.StatusBadRequest, - Title: e.Error(), - } } - return cErr + + return &openapi3filter.ValidationError{ + Status: http.StatusBadRequest, + Title: e.Error(), + } } func convertParseError(e *openapi3filter.RequestError, innerErr *openapi3filter.ParseError) *openapi3filter.ValidationError { var cErr *openapi3filter.ValidationError - // We treat path params of the wrong type like a 404 instead of a 400 + switch { case innerErr.Kind == openapi3filter.KindInvalidFormat && e.Parameter != nil && e.Parameter.In == "path": cErr = &openapi3filter.ValidationError{ @@ -138,40 +133,35 @@ func convertParseError(e *openapi3filter.RequestError, innerErr *openapi3filter. } } } + if cErr.Title == "" { cErr.Title = "Could not parse request body" } + return cErr } func convertSchemaError(e *openapi3filter.RequestError, innerErr *openapi3.SchemaError) *openapi3filter.ValidationError { cErr := &openapi3filter.ValidationError{Title: innerErr.Reason} - // Handle "Origin" error if originErr, ok := innerErr.Origin.(*openapi3.SchemaError); ok { cErr = convertSchemaError(e, originErr) } - // Add http status code if e.Parameter != nil { cErr.Status = http.StatusBadRequest } else if e.RequestBody != nil { cErr.Status = http.StatusUnprocessableEntity } - // Add error source if e.Parameter != nil && e.Parameter.In == "query" { - // We have a JSONPointer in the query param too so need to - // make sure 'Parameter' check takes priority over 'Pointer' cErr.Source = &openapi3filter.ValidationErrorSource{ Parameter: e.Parameter.Name, } cErr.Title += " See " + cErr.Source.Parameter } else if innerErr.JSONPointer() != nil { - pointer := innerErr.JSONPointer() - cErr.Source = &openapi3filter.ValidationErrorSource{ - Pointer: toJSONPointer(pointer), + Pointer: toJSONPointer(innerErr.JSONPointer()), } cErr.Title += " See " + cErr.Source.Pointer } @@ -179,28 +169,25 @@ func convertSchemaError(e *openapi3filter.RequestError, innerErr *openapi3.Schem if innerErr.SchemaField == "type" && strings.Contains(strings.ToLower(innerErr.Reason), "integer") && cErr.Source != nil && cErr.Source.Pointer != "" { - - cErr.Title = fmt.Sprintf( - "Field must be set to integer or not be present See %s", - cErr.Source.Pointer, - ) + cErr.Title = fmt.Sprintf("Field must be set to integer or not be present See %s", cErr.Source.Pointer) } - // Add details on allowed values for enums if innerErr.SchemaField == "enum" && innerErr.Reason == "JSON value is not one of the allowed values" { enums := make([]string, 0, len(innerErr.Schema.Enum)) for _, enum := range innerErr.Schema.Enum { enums = append(enums, fmt.Sprintf("%v", enum)) } + cErr.Detail = fmt.Sprintf("Value '%v' at %s must be one of: %s", innerErr.Value, toJSONPointer(innerErr.JSONPointer()), strings.Join(enums, ", ")) + value := fmt.Sprintf("%v", innerErr.Value) if (e.Parameter.Explode == nil || *e.Parameter.Explode) && (e.Parameter.Style == "" || e.Parameter.Style == "form") && strings.Contains(value, ",") { parts := strings.Split(value, ",") - cErr.Detail = cErr.Detail + "; " + fmt.Sprintf("perhaps you intended '?%s=%s'", + cErr.Detail += "; " + fmt.Sprintf("perhaps you intended '?%s=%s'", e.Parameter.Name, strings.Join(parts, "&"+e.Parameter.Name+"=")) } } diff --git a/pkg/gin-openapi-validator/validator.go b/pkg/gin-openapi-validator/validator.go index 058f5d3..4a25d3d 100644 --- a/pkg/gin-openapi-validator/validator.go +++ b/pkg/gin-openapi-validator/validator.go @@ -11,7 +11,7 @@ import ( log "github.com/sirupsen/logrus" ) -// responseBodyWriter captures the response body +// responseBodyWriter captures the response body. type responseBodyWriter struct { gin.ResponseWriter body *bytes.Buffer @@ -25,6 +25,7 @@ func (w *responseBodyWriter) Write(b []byte) (int, error) { if err == nil { w.body.Write(b[:n]) } + return n, err } @@ -35,35 +36,32 @@ func (w *responseBodyWriter) WriteHeader(code int) { } } -// Header returns the captured headers func (w *responseBodyWriter) Header() http.Header { return w.headers } -// flush writes the buffered response to the real writer func (w *responseBodyWriter) flush() { for k, vv := range w.headers { for _, v := range vv { w.ResponseWriter.Header().Add(k, v) } } + if w.statusCode == 0 { w.statusCode = http.StatusOK } + w.ResponseWriter.WriteHeader(w.statusCode) - // Flush body w.ResponseWriter.Write(w.body.Bytes()) } -// ValidatorOptions currently not used but we may use it in the future to add options. type ValidatorOptions struct { // If true, the middleware returns HTTP 500 when the response body // violates the OpenAPI specifications. StrictResponse bool } -// Validator returns a OpenAPI Validator middleware. It takes as argument doc where -// this is meant to be yaml byte array +// Validator returns an OpenAPI validation middleware for Gin. func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { var options ValidatorOptions if len(opts) > 0 { @@ -74,6 +72,7 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true + swagger, err := loader.LoadFromData(doc) if err != nil { panic("failed to load OpenAPI document: " + err.Error()) @@ -85,40 +84,19 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { } return func(c *gin.Context) { - // Find route route, pathParams, err := router.FindRoute(c.Request) if err != nil { - // Handle route not found / method not allowed - ve, decodeErr := Decode(err) - if decodeErr != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ - "error": "internal server error", - }) - } else { - c.AbortWithStatusJSON(ve.Status, gin.H{ - "error": ve.Title, - }) - } + abortForValidationError(c, err) return } - // Validate request requestValidationInput := &openapi3filter.RequestValidationInput{ Request: c.Request, PathParams: pathParams, Route: route, } if err = openapi3filter.ValidateRequest(c.Request.Context(), requestValidationInput); err != nil { - ve, decodeErr := Decode(err) - if decodeErr != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ - "error": "internal server error", - }) - } else { - c.AbortWithStatusJSON(ve.Status, gin.H{ - "error": ve.Title, - }) - } + abortForValidationError(c, err) return } @@ -129,7 +107,6 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { strict: options.StrictResponse, statusCode: http.StatusOK, } - // Copy original headers to our buffer for k, vv := range c.Writer.Header() { for _, v := range vv { w.headers.Add(k, v) @@ -137,7 +114,6 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { } c.Writer = w - c.Next() responseValidationInput := &openapi3filter.ResponseValidationInput{ @@ -145,7 +121,6 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { Status: w.statusCode, Header: w.headers, } - if w.body.Len() > 0 { responseValidationInput.SetBodyBytes(w.body.Bytes()) } @@ -155,15 +130,27 @@ func Validator(doc []byte, opts ...ValidatorOptions) gin.HandlerFunc { log.WithError(err).Error("response payload violates OpenAPI contract") if w.strict { - // Strict mode c.Writer.Header().Set("Content-Type", "application/json") c.Writer.WriteHeader(http.StatusInternalServerError) c.Writer.Write([]byte(`{"error":"Internal Server Error","detail":"Response body does not conform to the OpenAPI specification"}`)) return } - // Non-strict } w.flush() } } + +func abortForValidationError(c *gin.Context, err error) { + decodedValidationError, decodeErr := Decode(err) + if decodeErr != nil || decodedValidationError == nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + return + } + + c.AbortWithStatusJSON(decodedValidationError.Status, gin.H{ + "error": decodedValidationError.Title, + }) +} diff --git a/pkg/gin-openapi-validator/validator_test.go b/pkg/gin-openapi-validator/validator_test.go index 5d45644..d0f3069 100644 --- a/pkg/gin-openapi-validator/validator_test.go +++ b/pkg/gin-openapi-validator/validator_test.go @@ -26,7 +26,9 @@ var s []byte func TestMain(m *testing.M) { setupRouter() + hook = test.NewGlobal() + code := m.Run() os.Exit(code) } @@ -51,6 +53,7 @@ func setupRouter() { func request(request *http.Request) *httptest.ResponseRecorder { w := httptest.NewRecorder() r.ServeHTTP(w, request) + return w } @@ -72,6 +75,7 @@ func TestPostStatusOk(t *testing.T) { func TestStatusOkButWrongResponse(t *testing.T) { defer hook.Reset() + req, err := http.NewRequest(http.MethodGet, "/pets/1", nil) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -86,6 +90,7 @@ func TestStatusOkButWrongResponse(t *testing.T) { func TestStatusOkUsersUuid(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "/users?userId=bc1a80b7-6e76-4985-be3d-cbf8f8e79a2f", nil) assert.NoError(t, err) + resp := request(req) assert.Equal(t, http.StatusOK, resp.Code) } @@ -162,15 +167,20 @@ func TestBadRequests(t *testing.T) { testCase := tc t.Run(testCase.name, func(t *testing.T) { hook.Reset() + req, err := http.NewRequest(testCase.method, testCase.url, bytes.NewBuffer([]byte(testCase.body))) assert.NoError(t, err) + if testCase.setContentType { req.Header.Set("Content-Type", "application/json") } + resp := request(req) assert.Equal(t, testCase.expectedStatusCode, resp.Code) assert.Contains(t, resp.Body.String(), testCase.responseShouldContain) + var js json.RawMessage + assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), &js)) assert.Len(t, hook.Entries, 0) })