Merged in feature/openapifixes (pull request #106)

OpenAPI Middleware & Fixes

* updates
This commit is contained in:
Michael McGuinness
2025-03-18 13:06:42 +00:00
parent 555b6d420b
commit 3dd0c718cd
56 changed files with 8577 additions and 122 deletions
@@ -0,0 +1,29 @@
package openapi3filter
import (
"fmt"
"github.com/getkin/kin-openapi/openapi3"
)
type AuthenticationInput struct {
RequestValidationInput *RequestValidationInput
SecuritySchemeName string
SecurityScheme *openapi3.SecurityScheme
Scopes []string
}
func (input *AuthenticationInput) NewError(err error) error {
if err == nil {
if len(input.Scopes) == 0 {
err = fmt.Errorf("security requirement %q failed", input.SecuritySchemeName)
} else {
err = fmt.Errorf("security requirement %q (scopes: %+v) failed", input.SecuritySchemeName, input.Scopes)
}
}
return &RequestError{
Input: input.RequestValidationInput,
Reason: "authorization failed",
Err: err,
}
}
+97
View File
@@ -0,0 +1,97 @@
package openapi3filter
import (
"bytes"
"fmt"
"github.com/getkin/kin-openapi/openapi3"
)
var _ error = &RequestError{}
// RequestError is returned by ValidateRequest when request does not match OpenAPI spec
type RequestError struct {
Input *RequestValidationInput
Parameter *openapi3.Parameter
RequestBody *openapi3.RequestBody
Reason string
Err error
}
var _ interface{ Unwrap() error } = RequestError{}
func (err *RequestError) Error() string {
reason := err.Reason
if e := err.Err; e != nil {
if len(reason) == 0 || reason == e.Error() {
reason = e.Error()
} else {
reason += ": " + e.Error()
}
}
if v := err.Parameter; v != nil {
return fmt.Sprintf("parameter %q in %s has an error: %s", v.Name, v.In, reason)
} else if v := err.RequestBody; v != nil {
return fmt.Sprintf("request body has an error: %s", reason)
} else {
return reason
}
}
func (err RequestError) Unwrap() error {
return err.Err
}
var _ error = &ResponseError{}
// ResponseError is returned by ValidateResponse when response does not match OpenAPI spec
type ResponseError struct {
Input *ResponseValidationInput
Reason string
Err error
}
var _ interface{ Unwrap() error } = ResponseError{}
func (err *ResponseError) Error() string {
reason := err.Reason
if e := err.Err; e != nil {
if len(reason) == 0 {
reason = e.Error()
} else {
reason += ": " + e.Error()
}
}
return reason
}
func (err ResponseError) Unwrap() error {
return err.Err
}
var _ error = &SecurityRequirementsError{}
// SecurityRequirementsError is returned by ValidateSecurityRequirements
// when no requirement is met.
type SecurityRequirementsError struct {
SecurityRequirements openapi3.SecurityRequirements
Errors []error
}
func (err *SecurityRequirementsError) Error() string {
buff := bytes.NewBufferString("security requirements failed: ")
for i, e := range err.Errors {
buff.WriteString(e.Error())
if i != len(err.Errors)-1 {
buff.WriteString(" | ")
}
}
return buff.String()
}
var _ interface{ Unwrap() []error } = SecurityRequirementsError{}
func (err SecurityRequirementsError) Unwrap() []error {
return err.Errors
}
+25
View File
@@ -0,0 +1,25 @@
package openapi3filter
import (
"reflect"
"strings"
)
func parseMediaType(contentType string) string {
i := strings.IndexByte(contentType, ';')
if i < 0 {
return contentType
}
return contentType[:i]
}
func isNilValue(value any) bool {
if value == nil {
return true
}
switch reflect.TypeOf(value).Kind() {
case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice:
return reflect.ValueOf(value).IsNil()
}
return false
}
+284
View File
@@ -0,0 +1,284 @@
package openapi3filter
import (
"bytes"
"context"
"io"
"log"
"net/http"
"github.com/getkin/kin-openapi/routers"
)
// Validator provides HTTP request and response validation middleware.
type Validator struct {
router routers.Router
errFunc ErrFunc
logFunc LogFunc
strict bool
options Options
}
// ErrFunc handles errors that may occur during validation.
type ErrFunc func(ctx context.Context, w http.ResponseWriter, status int, code ErrCode, err error)
// LogFunc handles log messages that may occur during validation.
type LogFunc func(ctx context.Context, message string, err error)
// ErrCode is used for classification of different types of errors that may
// occur during validation. These may be used to write an appropriate response
// in ErrFunc.
type ErrCode int
const (
// ErrCodeOK indicates no error. It is also the default value.
ErrCodeOK = 0
// ErrCodeCannotFindRoute happens when the validator fails to resolve the
// request to a defined OpenAPI route.
ErrCodeCannotFindRoute = iota
// ErrCodeRequestInvalid happens when the inbound request does not conform
// to the OpenAPI 3 specification.
ErrCodeRequestInvalid = iota
// ErrCodeResponseInvalid happens when the wrapped handler response does
// not conform to the OpenAPI 3 specification.
ErrCodeResponseInvalid = iota
)
func (e ErrCode) responseText() string {
switch e {
case ErrCodeOK:
return "OK"
case ErrCodeCannotFindRoute:
return "not found"
case ErrCodeRequestInvalid:
return "bad request"
default:
return "server error"
}
}
// NewValidator returns a new response validation middleware, using the given
// routes from an OpenAPI 3 specification.
func NewValidator(router routers.Router, options ...ValidatorOption) *Validator {
v := &Validator{
router: router,
errFunc: func(_ context.Context, w http.ResponseWriter, status int, code ErrCode, _ error) {
http.Error(w, code.responseText(), status)
},
logFunc: func(_ context.Context, message string, err error) {
log.Printf("%s: %v", message, err)
},
}
for i := range options {
options[i](v)
}
return v
}
// ValidatorOption defines an option that may be specified when creating a
// Validator.
type ValidatorOption func(*Validator)
// OnErr provides a callback that handles writing an HTTP response on a
// validation error. This allows customization of error responses without
// prescribing a particular form. This callback is only called on response
// validator errors in Strict mode.
func OnErr(f ErrFunc) ValidatorOption {
return func(v *Validator) {
v.errFunc = f
}
}
// OnLog provides a callback that handles logging in the Validator. This allows
// the validator to integrate with a services' existing logging system without
// prescribing a particular one.
func OnLog(f LogFunc) ValidatorOption {
return func(v *Validator) {
v.logFunc = f
}
}
// Strict, if set, causes an internal server error to be sent if the wrapped
// handler response fails response validation. If not set, the response is sent
// and the error is only logged.
func Strict(strict bool) ValidatorOption {
return func(v *Validator) {
v.strict = strict
}
}
// ValidationOptions sets request/response validation options on the validator.
func ValidationOptions(options Options) ValidatorOption {
return func(v *Validator) {
v.options = options
}
}
// Middleware returns an http.Handler which wraps the given handler with
// request and response validation.
func (v *Validator) Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
route, pathParams, err := v.router.FindRoute(r)
if err != nil {
v.logFunc(ctx, "validation error: failed to find route for "+r.URL.String(), err)
v.errFunc(ctx, w, http.StatusNotFound, ErrCodeCannotFindRoute, err)
return
}
requestValidationInput := &RequestValidationInput{
Request: r,
PathParams: pathParams,
Route: route,
Options: &v.options,
}
if err = ValidateRequest(ctx, requestValidationInput); err != nil {
v.logFunc(ctx, "invalid request", err)
v.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)
return
}
var wr responseWrapper
if v.strict {
wr = &strictResponseWrapper{w: w}
} else {
wr = newWarnResponseWrapper(w)
}
h.ServeHTTP(wr, r)
if err = ValidateResponse(ctx, &ResponseValidationInput{
RequestValidationInput: requestValidationInput,
Status: wr.statusCode(),
Header: wr.Header(),
Body: io.NopCloser(bytes.NewBuffer(wr.bodyContents())),
Options: &v.options,
}); err != nil {
v.logFunc(ctx, "invalid response", err)
if v.strict {
v.errFunc(ctx, w, http.StatusInternalServerError, ErrCodeResponseInvalid, err)
}
return
}
if err = wr.flushBodyContents(); err != nil {
v.logFunc(ctx, "failed to write response", err)
}
})
}
type responseWrapper interface {
http.ResponseWriter
// flushBodyContents writes the buffered response to the client, if it has
// not yet been written.
flushBodyContents() error
// statusCode returns the response status code, 0 if not set yet.
statusCode() int
// bodyContents returns the buffered
bodyContents() []byte
}
type warnResponseWrapper struct {
w http.ResponseWriter
headerWritten bool
status int
body bytes.Buffer
tee io.Writer
}
func newWarnResponseWrapper(w http.ResponseWriter) *warnResponseWrapper {
wr := &warnResponseWrapper{
w: w,
}
wr.tee = io.MultiWriter(w, &wr.body)
return wr
}
// Write implements http.ResponseWriter.
func (wr *warnResponseWrapper) Write(b []byte) (int, error) {
if !wr.headerWritten {
wr.WriteHeader(http.StatusOK)
}
return wr.tee.Write(b)
}
// WriteHeader implements http.ResponseWriter.
func (wr *warnResponseWrapper) WriteHeader(status int) {
if !wr.headerWritten {
// If the header hasn't been written, record the status for response
// validation.
wr.status = status
wr.headerWritten = true
}
wr.w.WriteHeader(wr.status)
}
// Header implements http.ResponseWriter.
func (wr *warnResponseWrapper) Header() http.Header {
return wr.w.Header()
}
// Flush implements the optional http.Flusher interface.
func (wr *warnResponseWrapper) Flush() {
// If the wrapped http.ResponseWriter implements optional http.Flusher,
// pass through.
if fl, ok := wr.w.(http.Flusher); ok {
fl.Flush()
}
}
func (wr *warnResponseWrapper) flushBodyContents() error {
return nil
}
func (wr *warnResponseWrapper) statusCode() int {
return wr.status
}
func (wr *warnResponseWrapper) bodyContents() []byte {
return wr.body.Bytes()
}
type strictResponseWrapper struct {
w http.ResponseWriter
headerWritten bool
status int
body bytes.Buffer
}
// Write implements http.ResponseWriter.
func (wr *strictResponseWrapper) Write(b []byte) (int, error) {
if !wr.headerWritten {
wr.WriteHeader(http.StatusOK)
}
return wr.body.Write(b)
}
// WriteHeader implements http.ResponseWriter.
func (wr *strictResponseWrapper) WriteHeader(status int) {
if !wr.headerWritten {
wr.status = status
wr.headerWritten = true
}
}
// Header implements http.ResponseWriter.
func (wr *strictResponseWrapper) Header() http.Header {
return wr.w.Header()
}
func (wr *strictResponseWrapper) flushBodyContents() error {
wr.w.WriteHeader(wr.status)
_, err := wr.w.Write(wr.body.Bytes())
return err
}
func (wr *strictResponseWrapper) statusCode() int {
return wr.status
}
func (wr *strictResponseWrapper) bodyContents() []byte {
return wr.body.Bytes()
}
+50
View File
@@ -0,0 +1,50 @@
package openapi3filter
import "github.com/getkin/kin-openapi/openapi3"
// Options used by ValidateRequest and ValidateResponse
type Options struct {
// Set ExcludeRequestBody so ValidateRequest skips request body validation
ExcludeRequestBody bool
// Set ExcludeRequestQueryParams so ValidateRequest skips request query params validation
ExcludeRequestQueryParams bool
// Set ExcludeResponseBody so ValidateResponse skips response body validation
ExcludeResponseBody bool
// Set ExcludeReadOnlyValidations so ValidateRequest skips read-only validations
ExcludeReadOnlyValidations bool
// Set ExcludeWriteOnlyValidations so ValidateResponse skips write-only validations
ExcludeWriteOnlyValidations bool
// Set IncludeResponseStatus so ValidateResponse fails on response
// status not defined in OpenAPI spec
IncludeResponseStatus bool
MultiError bool
// Set RegexCompiler to override the regex implementation
RegexCompiler openapi3.RegexCompilerFunc
// A document with security schemes defined will not pass validation
// unless an AuthenticationFunc is defined.
// See NoopAuthenticationFunc
AuthenticationFunc AuthenticationFunc
// Indicates whether default values are set in the
// request. If true, then they are not set
SkipSettingDefaults bool
customSchemaErrorFunc CustomSchemaErrorFunc
}
// CustomSchemaErrorFunc allows for custom the schema error message.
type CustomSchemaErrorFunc func(err *openapi3.SchemaError) string
// WithCustomSchemaErrorFunc sets a function to override the schema error message.
// If the passed function returns an empty string, it returns to the previous Error() implementation.
func (o *Options) WithCustomSchemaErrorFunc(f CustomSchemaErrorFunc) {
o.customSchemaErrorFunc = f
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
package openapi3filter
import (
"encoding/json"
"fmt"
"sync"
)
func encodeBody(body any, mediaType string) ([]byte, error) {
if encoder := RegisteredBodyEncoder(mediaType); encoder != nil {
return encoder(body)
}
return nil, &ParseError{
Kind: KindUnsupportedFormat,
Reason: fmt.Sprintf("%s %q", prefixUnsupportedCT, mediaType),
}
}
// BodyEncoder really is an (encoding/json).Marshaler
type BodyEncoder func(body any) ([]byte, error)
var bodyEncodersM sync.RWMutex
var bodyEncoders = map[string]BodyEncoder{
"application/json": json.Marshal,
}
// RegisterBodyEncoder enables package-wide decoding of contentType values
func RegisterBodyEncoder(contentType string, encoder BodyEncoder) {
if contentType == "" {
panic("contentType is empty")
}
if encoder == nil {
panic("encoder is not defined")
}
bodyEncodersM.Lock()
bodyEncoders[contentType] = encoder
bodyEncodersM.Unlock()
}
// UnregisterBodyEncoder disables package-wide decoding of contentType values
func UnregisterBodyEncoder(contentType string) {
if contentType == "" {
panic("contentType is empty")
}
bodyEncodersM.Lock()
delete(bodyEncoders, contentType)
bodyEncodersM.Unlock()
}
// RegisteredBodyEncoder returns the registered body encoder for the given content type.
//
// If no encoder was registered for the given content type, nil is returned.
func RegisteredBodyEncoder(contentType string) BodyEncoder {
bodyEncodersM.RLock()
mayBE := bodyEncoders[contentType]
bodyEncodersM.RUnlock()
return mayBE
}
+431
View File
@@ -0,0 +1,431 @@
package openapi3filter
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"github.com/getkin/kin-openapi/openapi3"
)
// ErrAuthenticationServiceMissing is returned when no authentication service
// is defined for the request validator
var ErrAuthenticationServiceMissing = errors.New("missing AuthenticationFunc")
// ErrInvalidRequired is returned when a required value of a parameter or request body is not defined.
var ErrInvalidRequired = errors.New("value is required but missing")
// ErrInvalidEmptyValue is returned when a value of a parameter or request body is empty while it's not allowed.
var ErrInvalidEmptyValue = errors.New("empty value is not allowed")
// ValidateRequest is used to validate the given input according to previous
// loaded OpenAPIv3 spec. If the input does not match the OpenAPIv3 spec, a
// non-nil error will be returned.
//
// Note: One can tune the behavior of uniqueItems: true verification
// by registering a custom function with openapi3.RegisterArrayUniqueItemsChecker
func ValidateRequest(ctx context.Context, input *RequestValidationInput) error {
var me openapi3.MultiError
options := input.Options
if options == nil {
options = &Options{}
}
route := input.Route
operation := route.Operation
operationParameters := operation.Parameters
pathItemParameters := route.PathItem.Parameters
// Security
security := operation.Security
// If there aren't any security requirements for the operation
if security == nil {
// Use the global security requirements.
security = &route.Spec.Security
}
if security != nil {
if err := ValidateSecurityRequirements(ctx, input, *security); err != nil {
if !options.MultiError {
return err
}
me = append(me, err)
}
}
// For each parameter of the PathItem
for _, parameterRef := range pathItemParameters {
parameter := parameterRef.Value
if operationParameters != nil {
if override := operationParameters.GetByInAndName(parameter.In, parameter.Name); override != nil {
continue
}
}
if err := ValidateParameter(ctx, input, parameter); err != nil {
if !options.MultiError {
return err
}
me = append(me, err)
}
}
// For each parameter of the Operation
for _, parameter := range operationParameters {
if options.ExcludeRequestQueryParams && parameter.Value.In == openapi3.ParameterInQuery {
continue
}
if err := ValidateParameter(ctx, input, parameter.Value); err != nil {
if !options.MultiError {
return err
}
me = append(me, err)
}
}
// RequestBody
requestBody := operation.RequestBody
if requestBody != nil && !options.ExcludeRequestBody {
if err := ValidateRequestBody(ctx, input, requestBody.Value); err != nil {
if !options.MultiError {
return err
}
me = append(me, err)
}
}
if len(me) > 0 {
return me
}
return nil
}
// appendToQueryValues adds to query parameters each value in the provided slice
func appendToQueryValues[T any](q url.Values, parameterName string, v []T) {
for _, i := range v {
q.Add(parameterName, fmt.Sprintf("%v", i))
}
}
// populateDefaultQueryParameters populates default values inside query parameters, while ensuring types are respected
func populateDefaultQueryParameters(q url.Values, parameterName string, value any) {
switch t := value.(type) {
case []any:
appendToQueryValues(q, parameterName, t)
default:
q.Add(parameterName, fmt.Sprintf("%v", value))
}
}
// ValidateParameter validates a parameter's value by JSON schema.
// The function returns RequestError with a ParseError cause when unable to parse a value.
// The function returns RequestError with ErrInvalidRequired cause when a value of a required parameter is not defined.
// The function returns RequestError with ErrInvalidEmptyValue cause when a value of a required parameter is not defined.
// The function returns RequestError with a openapi3.SchemaError cause when a value is invalid by JSON schema.
func ValidateParameter(ctx context.Context, input *RequestValidationInput, parameter *openapi3.Parameter) error {
if parameter.Schema == nil && parameter.Content == nil {
// We have no schema for the parameter. Assume that everything passes
// a schema-less check, but this could also be an error. The OpenAPI
// validation allows this to happen.
return nil
}
options := input.Options
if options == nil {
options = &Options{}
}
var value any
var err error
var found bool
var schema *openapi3.Schema
// Validation will ensure that we either have content or schema.
if parameter.Content != nil {
if value, schema, found, err = decodeContentParameter(parameter, input); err != nil {
return &RequestError{Input: input, Parameter: parameter, Err: err}
}
} else {
if value, found, err = decodeStyledParameter(parameter, input); err != nil {
return &RequestError{Input: input, Parameter: parameter, Err: err}
}
schema = parameter.Schema.Value
}
// Set default value if needed
if !options.SkipSettingDefaults && value == nil && schema != nil {
value = schema.Default
for _, subSchema := range schema.AllOf {
if subSchema.Value.Default != nil {
value = subSchema.Value.Default
break // This is not a validation of the schema itself, so use the first default value.
}
}
if value != nil {
req := input.Request
switch parameter.In {
case openapi3.ParameterInPath:
// Path parameters are required.
// Next check `parameter.Required && !found` will catch this.
case openapi3.ParameterInQuery:
q := req.URL.Query()
populateDefaultQueryParameters(q, parameter.Name, value)
req.URL.RawQuery = q.Encode()
case openapi3.ParameterInHeader:
req.Header.Add(parameter.Name, fmt.Sprintf("%v", value))
case openapi3.ParameterInCookie:
req.AddCookie(&http.Cookie{
Name: parameter.Name,
Value: fmt.Sprintf("%v", value),
})
}
}
}
// Validate a parameter's value and presence.
if parameter.Required && !found {
return &RequestError{Input: input, Parameter: parameter, Reason: ErrInvalidRequired.Error(), Err: ErrInvalidRequired}
}
if isNilValue(value) {
if !parameter.AllowEmptyValue && found {
return &RequestError{Input: input, Parameter: parameter, Reason: ErrInvalidEmptyValue.Error(), Err: ErrInvalidEmptyValue}
}
return nil
}
if schema == nil {
// A parameter's schema is not defined so skip validation of a parameter's value.
return nil
}
var opts []openapi3.SchemaValidationOption
if options.MultiError {
opts = make([]openapi3.SchemaValidationOption, 0, 1)
opts = append(opts, openapi3.MultiErrors())
}
if options.customSchemaErrorFunc != nil {
opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc))
}
if err = schema.VisitJSON(value, opts...); err != nil {
return &RequestError{Input: input, Parameter: parameter, Err: err}
}
return nil
}
const prefixInvalidCT = "header Content-Type has unexpected value"
// ValidateRequestBody validates data of a request's body.
//
// The function returns RequestError with ErrInvalidRequired cause when a value is required but not defined.
// The function returns RequestError with a openapi3.SchemaError cause when a value is invalid by JSON schema.
func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, requestBody *openapi3.RequestBody) error {
var (
req = input.Request
data []byte
)
options := input.Options
if options == nil {
options = &Options{}
}
if req.Body != http.NoBody && req.Body != nil {
defer req.Body.Close()
var err error
if data, err = io.ReadAll(req.Body); err != nil {
return &RequestError{
Input: input,
RequestBody: requestBody,
Reason: "reading failed",
Err: err,
}
}
// Put the data back into the input
req.Body = nil
if req.GetBody != nil {
if req.Body, err = req.GetBody(); err != nil {
req.Body = nil
}
}
if req.Body == nil {
req.ContentLength = int64(len(data))
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
}
req.Body, _ = req.GetBody() // no error return
}
}
if len(data) == 0 {
if requestBody.Required {
return &RequestError{Input: input, RequestBody: requestBody, Err: ErrInvalidRequired}
}
return nil
}
content := requestBody.Content
if len(content) == 0 {
// A request's body does not have declared content, so skip validation.
return nil
}
inputMIME := req.Header.Get(headerCT)
contentType := requestBody.Content.Get(inputMIME)
if contentType == nil {
return &RequestError{
Input: input,
RequestBody: requestBody,
Reason: fmt.Sprintf("%s %q", prefixInvalidCT, inputMIME),
}
}
if contentType.Schema == nil {
// A JSON schema that describes the received data is not declared, so skip validation.
return nil
}
encFn := func(name string) *openapi3.Encoding { return contentType.Encoding[name] }
mediaType, value, err := decodeBody(bytes.NewReader(data), req.Header, contentType.Schema, encFn)
if err != nil {
return &RequestError{
Input: input,
RequestBody: requestBody,
Reason: "failed to decode request body",
Err: err,
}
}
defaultsSet := false
opts := make([]openapi3.SchemaValidationOption, 0, 4) // 4 potential opts here
opts = append(opts, openapi3.VisitAsRequest())
if !options.SkipSettingDefaults {
opts = append(opts, openapi3.DefaultsSet(func() { defaultsSet = true }))
}
if options.MultiError {
opts = append(opts, openapi3.MultiErrors())
}
if options.customSchemaErrorFunc != nil {
opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc))
}
if options.ExcludeReadOnlyValidations {
opts = append(opts, openapi3.DisableReadOnlyValidation())
}
if options.RegexCompiler != nil {
opts = append(opts, openapi3.SetSchemaRegexCompiler(options.RegexCompiler))
}
// Validate JSON with the schema
if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil {
schemaId := getSchemaIdentifier(contentType.Schema)
schemaId = prependSpaceIfNeeded(schemaId)
return &RequestError{
Input: input,
RequestBody: requestBody,
Reason: fmt.Sprintf("doesn't match schema%s", schemaId),
Err: err,
}
}
if defaultsSet {
var err error
if data, err = encodeBody(value, mediaType); err != nil {
return &RequestError{
Input: input,
RequestBody: requestBody,
Reason: "rewriting failed",
Err: err,
}
}
// Put the data back into the input
if req.Body != nil {
req.Body.Close()
}
req.ContentLength = int64(len(data))
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
}
req.Body, _ = req.GetBody() // no error return
}
return nil
}
// ValidateSecurityRequirements goes through multiple OpenAPI 3 security
// requirements in order and returns nil on the first valid requirement.
// If no requirement is met, errors are returned in order.
func ValidateSecurityRequirements(ctx context.Context, input *RequestValidationInput, srs openapi3.SecurityRequirements) error {
if len(srs) == 0 {
return nil
}
var errs []error
for _, sr := range srs {
if err := validateSecurityRequirement(ctx, input, sr); err != nil {
if len(errs) == 0 {
errs = make([]error, 0, len(srs))
}
errs = append(errs, err)
continue
}
return nil
}
return &SecurityRequirementsError{
SecurityRequirements: srs,
Errors: errs,
}
}
// validateSecurityRequirement validates a single OpenAPI 3 security requirement
func validateSecurityRequirement(ctx context.Context, input *RequestValidationInput, securityRequirement openapi3.SecurityRequirement) error {
names := make([]string, 0, len(securityRequirement))
for name := range securityRequirement {
names = append(names, name)
}
sort.Strings(names)
// Get authentication function
options := input.Options
if options == nil {
options = &Options{}
}
f := options.AuthenticationFunc
if f == nil {
return ErrAuthenticationServiceMissing
}
var securitySchemes openapi3.SecuritySchemes
if components := input.Route.Spec.Components; components != nil {
securitySchemes = components.SecuritySchemes
}
// For each scheme for the requirement
for _, name := range names {
var securityScheme *openapi3.SecurityScheme
if securitySchemes != nil {
if ref := securitySchemes[name]; ref != nil {
securityScheme = ref.Value
}
}
if securityScheme == nil {
return &RequestError{
Input: input,
Err: fmt.Errorf("security scheme %q is not declared", name),
}
}
scopes := securityRequirement[name]
if err := f(ctx, &AuthenticationInput{
RequestValidationInput: input,
SecuritySchemeName: name,
SecurityScheme: securityScheme,
Scopes: scopes,
}); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,38 @@
package openapi3filter
import (
"net/http"
"net/url"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"
)
// A ContentParameterDecoder takes a parameter definition from the OpenAPI spec,
// and the value which we received for it. It is expected to return the
// value unmarshaled into an interface which can be traversed for
// validation, it should also return the schema to be used for validating the
// object, since there can be more than one in the content spec.
//
// If a query parameter appears multiple times, values[] will have more
// than one value, but for all other parameter types it should have just
// one.
type ContentParameterDecoder func(param *openapi3.Parameter, values []string) (any, *openapi3.Schema, error)
type RequestValidationInput struct {
Request *http.Request
PathParams map[string]string
QueryParams url.Values
Route *routers.Route
Options *Options
ParamDecoder ContentParameterDecoder
}
func (input *RequestValidationInput) GetQueryParams() url.Values {
q := input.QueryParams
if q == nil {
q = input.Request.URL.Query()
input.QueryParams = q
}
return q
}
@@ -0,0 +1,224 @@
// Package openapi3filter validates that requests and inputs request an OpenAPI 3 specification file.
package openapi3filter
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sort"
"strings"
"github.com/getkin/kin-openapi/openapi3"
)
// ValidateResponse is used to validate the given input according to previous
// loaded OpenAPIv3 spec. If the input does not match the OpenAPIv3 spec, a
// non-nil error will be returned.
//
// Note: One can tune the behavior of uniqueItems: true verification
// by registering a custom function with openapi3.RegisterArrayUniqueItemsChecker
func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error {
req := input.RequestValidationInput.Request
switch req.Method {
case "HEAD":
return nil
}
status := input.Status
// These status codes will never be validated.
// TODO: The list is probably missing some.
switch status {
case http.StatusNotModified,
http.StatusPermanentRedirect,
http.StatusTemporaryRedirect,
http.StatusMovedPermanently:
return nil
}
route := input.RequestValidationInput.Route
options := input.Options
if options == nil {
options = &Options{}
}
// Find input for the current status
responses := route.Operation.Responses
if responses.Len() == 0 {
return nil
}
responseRef := responses.Status(status) // Response
if responseRef == nil {
responseRef = responses.Default() // Default input
}
if responseRef == nil {
// By default, status that is not documented is allowed.
if !options.IncludeResponseStatus {
return nil
}
return &ResponseError{Input: input, Reason: "status is not supported"}
}
response := responseRef.Value
if response == nil {
return &ResponseError{Input: input, Reason: "response has not been resolved"}
}
opts := make([]openapi3.SchemaValidationOption, 0, 3) // 3 potential options here
if options.MultiError {
opts = append(opts, openapi3.MultiErrors())
}
if options.customSchemaErrorFunc != nil {
opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc))
}
if options.ExcludeWriteOnlyValidations {
opts = append(opts, openapi3.DisableWriteOnlyValidation())
}
headers := make([]string, 0, len(response.Headers))
for k := range response.Headers {
if k != headerCT {
headers = append(headers, k)
}
}
sort.Strings(headers)
for _, headerName := range headers {
headerRef := response.Headers[headerName]
if err := validateResponseHeader(headerName, headerRef, input, opts); err != nil {
return err
}
}
if options.ExcludeResponseBody {
// A user turned off validation of a response's body.
return nil
}
content := response.Content
if len(content) == 0 || options.ExcludeResponseBody {
// An operation does not contains a validation schema for responses with this status code.
return nil
}
inputMIME := input.Header.Get(headerCT)
contentType := content.Get(inputMIME)
if contentType == nil {
return &ResponseError{
Input: input,
Reason: fmt.Sprintf("response %s: %q", prefixInvalidCT, inputMIME),
}
}
if contentType.Schema == nil {
// An operation does not contains a validation schema for responses with this status code.
return nil
}
// Read response's body.
body := input.Body
// Response would contain partial or empty input body
// after we begin reading.
// Ensure that this doesn't happen.
input.Body = nil
// Ensure we close the reader
defer body.Close()
// Read all
data, err := io.ReadAll(body)
if err != nil {
return &ResponseError{
Input: input,
Reason: "failed to read response body",
Err: err,
}
}
// Put the data back into the response.
input.SetBodyBytes(data)
encFn := func(name string) *openapi3.Encoding { return contentType.Encoding[name] }
_, value, err := decodeBody(bytes.NewBuffer(data), input.Header, contentType.Schema, encFn)
if err != nil {
return &ResponseError{
Input: input,
Reason: "failed to decode response body",
Err: err,
}
}
// Validate data with the schema.
if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil {
schemaId := getSchemaIdentifier(contentType.Schema)
schemaId = prependSpaceIfNeeded(schemaId)
return &ResponseError{
Input: input,
Reason: fmt.Sprintf("response body doesn't match schema%s", schemaId),
Err: err,
}
}
return nil
}
func validateResponseHeader(headerName string, headerRef *openapi3.HeaderRef, input *ResponseValidationInput, opts []openapi3.SchemaValidationOption) error {
var err error
var decodedValue any
var found bool
var sm *openapi3.SerializationMethod
dec := &headerParamDecoder{header: input.Header}
if sm, err = headerRef.Value.SerializationMethod(); err != nil {
return &ResponseError{
Input: input,
Reason: fmt.Sprintf("unable to get header %q serialization method", headerName),
Err: err,
}
}
if decodedValue, found, err = decodeValue(dec, headerName, sm, headerRef.Value.Schema, headerRef.Value.Required); err != nil {
return &ResponseError{
Input: input,
Reason: fmt.Sprintf("unable to decode header %q value", headerName),
Err: err,
}
}
if found {
if err = headerRef.Value.Schema.Value.VisitJSON(decodedValue, opts...); err != nil {
return &ResponseError{
Input: input,
Reason: fmt.Sprintf("response header %q doesn't match schema", headerName),
Err: err,
}
}
} else if headerRef.Value.Required {
return &ResponseError{
Input: input,
Reason: fmt.Sprintf("response header %q missing", headerName),
}
}
return nil
}
// getSchemaIdentifier gets something by which a schema could be identified.
// A schema by itself doesn't have a true identity field. This function makes
// a best effort to get a value that can fill that void.
func getSchemaIdentifier(schema *openapi3.SchemaRef) string {
var id string
if schema != nil {
id = strings.TrimSpace(schema.Ref)
}
if id == "" && schema.Value != nil {
id = strings.TrimSpace(schema.Value.Title)
}
return id
}
func prependSpaceIfNeeded(value string) string {
if len(value) > 0 {
value = " " + value
}
return value
}
@@ -0,0 +1,41 @@
package openapi3filter
import (
"bytes"
"io"
"net/http"
)
type ResponseValidationInput struct {
RequestValidationInput *RequestValidationInput
Status int
Header http.Header
Body io.ReadCloser
Options *Options
}
func (input *ResponseValidationInput) SetBodyBytes(value []byte) *ResponseValidationInput {
input.Body = io.NopCloser(bytes.NewReader(value))
return input
}
var JSONPrefixes = []string{
")]}',\n",
}
// TrimJSONPrefix trims one of the possible prefixes
func TrimJSONPrefix(data []byte) []byte {
search:
for _, prefix := range JSONPrefixes {
if len(data) < len(prefix) {
continue
}
for i, b := range data[:len(prefix)] {
if b != prefix[i] {
continue search
}
}
return data[len(prefix):]
}
return data
}
@@ -0,0 +1,84 @@
package openapi3filter
import (
"bytes"
"strconv"
)
// ValidationError struct provides granular error information
// useful for communicating issues back to end user and developer.
// Based on https://jsonapi.org/format/#error-objects
type ValidationError struct {
// A unique identifier for this particular occurrence of the problem.
Id string `json:"id,omitempty" yaml:"id,omitempty"`
// The HTTP status code applicable to this problem.
Status int `json:"status,omitempty" yaml:"status,omitempty"`
// An application-specific error code, expressed as a string value.
Code string `json:"code,omitempty" yaml:"code,omitempty"`
// A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.
Title string `json:"title,omitempty" yaml:"title,omitempty"`
// A human-readable explanation specific to this occurrence of the problem.
Detail string `json:"detail,omitempty" yaml:"detail,omitempty"`
// An object containing references to the source of the error
Source *ValidationErrorSource `json:"source,omitempty" yaml:"source,omitempty"`
}
// ValidationErrorSource struct
type ValidationErrorSource struct {
// A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].
Pointer string `json:"pointer,omitempty" yaml:"pointer,omitempty"`
// A string indicating which query parameter caused the error.
Parameter string `json:"parameter,omitempty" yaml:"parameter,omitempty"`
}
var _ error = &ValidationError{}
// Error implements the error interface.
func (e *ValidationError) Error() string {
b := bytes.NewBufferString("[")
if e.Status != 0 {
b.WriteString(strconv.Itoa(e.Status))
}
b.WriteString("]")
b.WriteString("[")
if e.Code != "" {
b.WriteString(e.Code)
}
b.WriteString("]")
b.WriteString("[")
if e.Id != "" {
b.WriteString(e.Id)
}
b.WriteString("]")
b.WriteString(" ")
if e.Title != "" {
b.WriteString(e.Title)
b.WriteString(" ")
}
if e.Detail != "" {
b.WriteString("| ")
b.WriteString(e.Detail)
b.WriteString(" ")
}
if e.Source != nil {
b.WriteString("[source ")
if e.Source.Parameter != "" {
b.WriteString("parameter=")
b.WriteString(e.Source.Parameter)
} else if e.Source.Pointer != "" {
b.WriteString("pointer=")
b.WriteString(e.Source.Pointer)
}
b.WriteString("]")
}
if b.Len() == 0 {
return "no error"
}
return b.String()
}
// StatusCode implements the StatusCoder interface for DefaultErrorEncoder
func (e *ValidationError) StatusCode() int {
return e.Status
}
@@ -0,0 +1,186 @@
package openapi3filter
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"
)
// ValidationErrorEncoder wraps a base ErrorEncoder to handle ValidationErrors
type ValidationErrorEncoder struct {
Encoder ErrorEncoder
}
// Encode implements the ErrorEncoder interface for encoding ValidationErrors
func (enc *ValidationErrorEncoder) Encode(ctx context.Context, err error, w http.ResponseWriter) {
enc.Encoder(ctx, ConvertErrors(err), w)
}
// ConvertErrors converts all errors to the appropriate error format.
func ConvertErrors(err error) error {
if e, ok := err.(*routers.RouteError); ok {
return convertRouteError(e)
}
e, ok := err.(*RequestError)
if !ok {
return err
}
var cErr *ValidationError
if e.Err == nil {
cErr = convertBasicRequestError(e)
} else if e.Err == ErrInvalidRequired {
cErr = convertErrInvalidRequired(e)
} else if e.Err == ErrInvalidEmptyValue {
cErr = convertErrInvalidEmptyValue(e)
} else if innerErr, ok := e.Err.(*ParseError); ok {
cErr = convertParseError(e, innerErr)
} else if innerErr, ok := e.Err.(*openapi3.SchemaError); ok {
cErr = convertSchemaError(e, innerErr)
}
if cErr != nil {
return cErr
}
return err
}
func convertRouteError(e *routers.RouteError) *ValidationError {
status := http.StatusNotFound
if e.Error() == routers.ErrMethodNotAllowed.Error() {
status = http.StatusMethodNotAllowed
}
return &ValidationError{Status: status, Title: e.Error()}
}
func convertBasicRequestError(e *RequestError) *ValidationError {
if strings.HasPrefix(e.Reason, prefixInvalidCT) {
if strings.HasSuffix(e.Reason, `""`) {
return &ValidationError{
Status: http.StatusUnsupportedMediaType,
Title: "header Content-Type is required",
}
}
return &ValidationError{
Status: http.StatusUnsupportedMediaType,
Title: prefixUnsupportedCT + strings.TrimPrefix(e.Reason, prefixInvalidCT),
}
}
return &ValidationError{
Status: http.StatusBadRequest,
Title: e.Error(),
}
}
func convertErrInvalidRequired(e *RequestError) *ValidationError {
if e.Err == ErrInvalidRequired && e.Parameter != nil {
return &ValidationError{
Status: http.StatusBadRequest,
Title: fmt.Sprintf("parameter %q in %s is required", e.Parameter.Name, e.Parameter.In),
}
}
return &ValidationError{
Status: http.StatusBadRequest,
Title: e.Error(),
}
}
func convertErrInvalidEmptyValue(e *RequestError) *ValidationError {
if e.Err == ErrInvalidEmptyValue && e.Parameter != nil {
return &ValidationError{
Status: http.StatusBadRequest,
Title: fmt.Sprintf("parameter %q in %s is not allowed to be empty", e.Parameter.Name, e.Parameter.In),
}
}
return &ValidationError{
Status: http.StatusBadRequest,
Title: e.Error(),
}
}
func convertParseError(e *RequestError, innerErr *ParseError) *ValidationError {
// We treat path params of the wrong type like a 404 instead of a 400
if innerErr.Kind == KindInvalidFormat && e.Parameter != nil && e.Parameter.In == "path" {
return &ValidationError{
Status: http.StatusNotFound,
Title: fmt.Sprintf("resource not found with %q value: %v", e.Parameter.Name, innerErr.Value),
}
} else if strings.HasPrefix(innerErr.Reason, prefixUnsupportedCT) {
return &ValidationError{
Status: http.StatusUnsupportedMediaType,
Title: innerErr.Reason,
}
} else if innerErr.RootCause() != nil {
if rootErr, ok := innerErr.Cause.(*ParseError); ok &&
rootErr.Kind == KindInvalidFormat && e.Parameter.In == "query" {
return &ValidationError{
Status: http.StatusBadRequest,
Title: fmt.Sprintf("parameter %q in %s is invalid: %v is %s",
e.Parameter.Name, e.Parameter.In, rootErr.Value, rootErr.Reason),
}
}
return &ValidationError{
Status: http.StatusBadRequest,
Title: innerErr.Reason,
}
}
return nil
}
func convertSchemaError(e *RequestError, innerErr *openapi3.SchemaError) *ValidationError {
cErr := &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 {
// We have a JSONPointer in the query param too so need to
// make sure 'Parameter' check takes priority over 'Pointer'
cErr.Source = &ValidationErrorSource{Parameter: e.Parameter.Name}
} else if ptr := innerErr.JSONPointer(); ptr != nil {
cErr.Source = &ValidationErrorSource{Pointer: toJSONPointer(ptr)}
}
// Add details on allowed values for enums
if innerErr.SchemaField == "enum" {
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 != nil &&
(e.Parameter.Explode == nil || *e.Parameter.Explode) &&
(e.Parameter.Style == "" || e.Parameter.Style == "form") &&
strings.Contains(value, ",") {
parts := strings.Split(value, ",")
cErr.Detail = fmt.Sprintf("%s; perhaps you intended '?%s=%s'",
cErr.Detail,
e.Parameter.Name,
strings.Join(parts, "&"+e.Parameter.Name+"="))
}
}
return cErr
}
func toJSONPointer(reversePath []string) string {
return "/" + strings.Join(reversePath, "/")
}
@@ -0,0 +1,107 @@
package openapi3filter
import (
"context"
"net/http"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"
legacyrouter "github.com/getkin/kin-openapi/routers/legacy"
)
// AuthenticationFunc allows for custom security requirement validation.
// A non-nil error fails authentication according to https://spec.openapis.org/oas/v3.1.0#security-requirement-object
// See ValidateSecurityRequirements
type AuthenticationFunc func(context.Context, *AuthenticationInput) error
// NoopAuthenticationFunc is an AuthenticationFunc
func NoopAuthenticationFunc(context.Context, *AuthenticationInput) error { return nil }
var _ AuthenticationFunc = NoopAuthenticationFunc
type ValidationHandler struct {
Handler http.Handler
AuthenticationFunc AuthenticationFunc
File string
ErrorEncoder ErrorEncoder
router routers.Router
}
func (h *ValidationHandler) Load() error {
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(h.File)
if err != nil {
return err
}
if err := doc.Validate(loader.Context); err != nil {
return err
}
if h.router, err = legacyrouter.NewRouter(doc); err != nil {
return err
}
// set defaults
if h.Handler == nil {
h.Handler = http.DefaultServeMux
}
if h.AuthenticationFunc == nil {
h.AuthenticationFunc = NoopAuthenticationFunc
}
if h.ErrorEncoder == nil {
h.ErrorEncoder = DefaultErrorEncoder
}
return nil
}
func (h *ValidationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if handled := h.before(w, r); handled {
return
}
// TODO: validateResponse
h.Handler.ServeHTTP(w, r)
}
// Middleware implements gorilla/mux MiddlewareFunc
func (h *ValidationHandler) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if handled := h.before(w, r); handled {
return
}
// TODO: validateResponse
next.ServeHTTP(w, r)
})
}
func (h *ValidationHandler) before(w http.ResponseWriter, r *http.Request) (handled bool) {
if err := h.validateRequest(r); err != nil {
h.ErrorEncoder(r.Context(), err, w)
return true
}
return false
}
func (h *ValidationHandler) validateRequest(r *http.Request) error {
// Find route
route, pathParams, err := h.router.FindRoute(r)
if err != nil {
return err
}
options := &Options{
AuthenticationFunc: h.AuthenticationFunc,
}
// Validate request
requestValidationInput := &RequestValidationInput{
Request: r,
PathParams: pathParams,
Route: route,
Options: options,
}
if err = ValidateRequest(r.Context(), requestValidationInput); err != nil {
return err
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package openapi3filter
import (
"context"
"encoding/json"
"net/http"
)
///////////////////////////////////////////////////////////////////////////////////
// We didn't want to tie kin-openapi too tightly with go-kit.
// This file contains the ErrorEncoder and DefaultErrorEncoder function
// borrowed from this project.
//
// The MIT License (MIT)
//
// Copyright (c) 2015 Peter Bourgon
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
///////////////////////////////////////////////////////////////////////////////////
// ErrorEncoder is responsible for encoding an error to the ResponseWriter.
// Users are encouraged to use custom ErrorEncoders to encode HTTP errors to
// their clients, and will likely want to pass and check for their own error
// types. See the example shipping/handling service.
type ErrorEncoder func(ctx context.Context, err error, w http.ResponseWriter)
// StatusCoder is checked by DefaultErrorEncoder. If an error value implements
// StatusCoder, the StatusCode will be used when encoding the error. By default,
// StatusInternalServerError (500) is used.
type StatusCoder interface {
StatusCode() int
}
// Headerer is checked by DefaultErrorEncoder. If an error value implements
// Headerer, the provided headers will be applied to the response writer, after
// the Content-Type is set.
type Headerer interface {
Headers() http.Header
}
// DefaultErrorEncoder writes the error to the ResponseWriter, by default a
// content type of text/plain, a body of the plain text of the error, and a
// status code of 500. If the error implements Headerer, the provided headers
// will be applied to the response. If the error implements json.Marshaler, and
// the marshaling succeeds, a content type of application/json and the JSON
// encoded form of the error will be used. If the error implements StatusCoder,
// the provided StatusCode will be used instead of 500.
func DefaultErrorEncoder(_ context.Context, err error, w http.ResponseWriter) {
contentType, body := "text/plain; charset=utf-8", []byte(err.Error())
if marshaler, ok := err.(json.Marshaler); ok {
if jsonBody, marshalErr := marshaler.MarshalJSON(); marshalErr == nil {
contentType, body = "application/json; charset=utf-8", jsonBody
}
}
w.Header().Set("Content-Type", contentType)
if headerer, ok := err.(Headerer); ok {
for k, values := range headerer.Headers() {
for _, v := range values {
w.Header().Add(k, v)
}
}
}
code := http.StatusInternalServerError
if sc, ok := err.(StatusCoder); ok {
code = sc.StatusCode()
}
w.WriteHeader(code)
w.Write(body)
}
+263
View File
@@ -0,0 +1,263 @@
// Package gorillamux implements a router.
//
// It differs from the legacy router:
// * it provides somewhat granular errors: "path not found", "method not allowed".
// * it handles matching routes with extensions (e.g. /books/{id}.json)
// * it handles path patterns with a different syntax (e.g. /params/{x}/{y}/{z:.*})
package gorillamux
import (
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"github.com/gorilla/mux"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"
)
var _ routers.Router = &Router{}
// Router helps link http.Request.s and an OpenAPIv3 spec
type Router struct {
muxes []routeMux
routes []*routers.Route
}
type varsf func(vars map[string]string)
type routeMux struct {
muxRoute *mux.Route
varsUpdater varsf
}
type srv struct {
schemes []string
host, base string
server *openapi3.Server
varsUpdater varsf
}
var singleVariableMatcher = regexp.MustCompile(`^\{([^{}]+)\}$`)
// TODO: Handle/HandlerFunc + ServeHTTP (When there is a match, the route variables can be retrieved calling mux.Vars(request))
// NewRouter creates a gorilla/mux router.
// Assumes spec is .Validate()d
// Note that a variable for the port number MUST have a default value and only this value will match as the port (see issue #367).
func NewRouter(doc *openapi3.T) (routers.Router, error) {
servers, err := makeServers(doc.Servers)
if err != nil {
return nil, err
}
muxRouter := mux.NewRouter().UseEncodedPath()
r := &Router{}
for _, path := range doc.Paths.InMatchingOrder() {
pathItem := doc.Paths.Value(path)
if len(pathItem.Servers) > 0 {
if servers, err = makeServers(pathItem.Servers); err != nil {
return nil, err
}
}
operations := pathItem.Operations()
methods := make([]string, 0, len(operations))
for method := range operations {
methods = append(methods, method)
}
sort.Strings(methods)
for _, s := range servers {
muxRoute := muxRouter.Path(s.base + path).Methods(methods...)
if schemes := s.schemes; len(schemes) != 0 {
muxRoute.Schemes(schemes...)
}
if host := s.host; host != "" {
muxRoute.Host(host)
}
if err := muxRoute.GetError(); err != nil {
return nil, err
}
r.muxes = append(r.muxes, routeMux{
muxRoute: muxRoute,
varsUpdater: s.varsUpdater,
})
r.routes = append(r.routes, &routers.Route{
Spec: doc,
Server: s.server,
Path: path,
PathItem: pathItem,
Method: "",
Operation: nil,
})
}
}
return r, nil
}
// FindRoute extracts the route and parameters of an http.Request
func (r *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string, error) {
for i, m := range r.muxes {
var match mux.RouteMatch
if m.muxRoute.Match(req, &match) {
if err := match.MatchErr; err != nil {
// What then?
}
vars := match.Vars
if f := m.varsUpdater; f != nil {
f(vars)
}
route := *r.routes[i]
route.Method = req.Method
route.Operation = route.Spec.Paths.Value(route.Path).GetOperation(route.Method)
return &route, vars, nil
}
switch match.MatchErr {
case nil:
case mux.ErrMethodMismatch:
return nil, nil, routers.ErrMethodNotAllowed
default: // What then?
}
}
return nil, nil, routers.ErrPathNotFound
}
func makeServers(in openapi3.Servers) ([]srv, error) {
servers := make([]srv, 0, len(in))
for _, server := range in {
serverURL := server.URL
if submatch := singleVariableMatcher.FindStringSubmatch(serverURL); submatch != nil {
sVar := submatch[1]
sVal := server.Variables[sVar].Default
serverURL = strings.ReplaceAll(serverURL, "{"+sVar+"}", sVal)
var varsUpdater varsf
if lhs := strings.TrimSuffix(serverURL, server.Variables[sVar].Default); lhs != "" {
varsUpdater = func(vars map[string]string) { vars[sVar] = lhs }
}
svr, err := newSrv(serverURL, server, varsUpdater)
if err != nil {
return nil, err
}
servers = append(servers, svr)
continue
}
// If a variable represents the port "http://domain.tld:{port}/bla"
// then url.Parse() cannot parse "http://domain.tld:`bEncode({port})`/bla"
// and mux is not able to set the {port} variable
// So we just use the default value for this variable.
// See https://github.com/getkin/kin-openapi/issues/367
var varsUpdater varsf
if lhs := strings.Index(serverURL, ":{"); lhs > 0 {
rest := serverURL[lhs+len(":{"):]
rhs := strings.Index(rest, "}")
portVariable := rest[:rhs]
portValue := server.Variables[portVariable].Default
serverURL = strings.ReplaceAll(serverURL, "{"+portVariable+"}", portValue)
varsUpdater = func(vars map[string]string) {
vars[portVariable] = portValue
}
}
svr, err := newSrv(serverURL, server, varsUpdater)
if err != nil {
return nil, err
}
servers = append(servers, svr)
}
if len(servers) == 0 {
servers = append(servers, srv{})
}
return servers, nil
}
func newSrv(serverURL string, server *openapi3.Server, varsUpdater varsf) (srv, error) {
var schemes []string
if strings.Contains(serverURL, "://") {
scheme0 := strings.Split(serverURL, "://")[0]
schemes = permutePart(scheme0, server)
serverURL = strings.Replace(serverURL, scheme0+"://", schemes[0]+"://", 1)
}
u, err := url.Parse(bEncode(serverURL))
if err != nil {
return srv{}, err
}
path := bDecode(u.EscapedPath())
if len(path) > 0 && path[len(path)-1] == '/' {
path = path[:len(path)-1]
}
svr := srv{
host: bDecode(u.Host), //u.Hostname()?
base: path,
schemes: schemes, // scheme: []string{scheme0}, TODO: https://github.com/gorilla/mux/issues/624
server: server,
varsUpdater: varsUpdater,
}
return svr, nil
}
// Magic strings that temporarily replace "{}" so net/url.Parse() works
var blURL, brURL = strings.Repeat("-", 50), strings.Repeat("_", 50)
func bEncode(s string) string {
s = strings.Replace(s, "{", blURL, -1)
s = strings.Replace(s, "}", brURL, -1)
return s
}
func bDecode(s string) string {
s = strings.Replace(s, blURL, "{", -1)
s = strings.Replace(s, brURL, "}", -1)
return s
}
func permutePart(part0 string, srv *openapi3.Server) []string {
type mapAndSlice struct {
m map[string]struct{}
s []string
}
var2val := make(map[string]mapAndSlice)
max := 0
for name0, v := range srv.Variables {
name := "{" + name0 + "}"
if !strings.Contains(part0, name) {
continue
}
m := map[string]struct{}{v.Default: {}}
for _, value := range v.Enum {
m[value] = struct{}{}
}
if l := len(m); l > max {
max = l
}
s := make([]string, 0, len(m))
for value := range m {
s = append(s, value)
}
var2val[name] = mapAndSlice{m: m, s: s}
}
if len(var2val) == 0 {
return []string{part0}
}
partsMap := make(map[string]struct{}, max*len(var2val))
for i := 0; i < max; i++ {
part := part0
for name, mas := range var2val {
part = strings.Replace(part, name, mas.s[i%len(mas.s)], -1)
}
partsMap[part] = struct{}{}
}
parts := make([]string, 0, len(partsMap))
for part := range partsMap {
parts = append(parts, part)
}
sort.Strings(parts)
return parts
}
+328
View File
@@ -0,0 +1,328 @@
// Package pathpattern implements path matching.
//
// Examples of supported patterns:
// - "/"
// - "/abc""
// - "/abc/{variable}" (matches until next '/' or end-of-string)
// - "/abc/{variable*}" (matches everything, including "/abc" if "/abc" has root)
// - "/abc/{ variable | prefix_(.*}_suffix }" (matches regular expressions)
package pathpattern
import (
"bytes"
"fmt"
"regexp"
"sort"
"strings"
)
var DefaultOptions = &Options{
SupportWildcard: true,
}
type Options struct {
SupportWildcard bool
SupportRegExp bool
}
// PathFromHost converts a host pattern to a path pattern.
//
// Examples:
// - PathFromHost("some-subdomain.domain.com", false) -> "com/./domain/./some-subdomain"
// - PathFromHost("some-subdomain.domain.com", true) -> "com/./domain/./subdomain/-/some"
func PathFromHost(host string, specialDashes bool) string {
buf := make([]byte, 0, len(host))
end := len(host)
// Go from end to start
for start := end - 1; start >= 0; start-- {
switch host[start] {
case '.':
buf = append(buf, host[start+1:end]...)
buf = append(buf, '/', '.', '/')
end = start
case '-':
if specialDashes {
buf = append(buf, host[start+1:end]...)
buf = append(buf, '/', '-', '/')
end = start
}
}
}
buf = append(buf, host[:end]...)
return string(buf)
}
type Node struct {
VariableNames []string
Value any
Suffixes SuffixList
}
func (currentNode *Node) String() string {
buf := bytes.NewBuffer(make([]byte, 0, 255))
currentNode.toBuffer(buf, "")
return buf.String()
}
func (currentNode *Node) toBuffer(buf *bytes.Buffer, linePrefix string) {
if value := currentNode.Value; value != nil {
buf.WriteString(linePrefix)
buf.WriteString("VALUE: ")
fmt.Fprint(buf, value)
buf.WriteString("\n")
}
suffixes := currentNode.Suffixes
if len(suffixes) > 0 {
newLinePrefix := linePrefix + " "
for _, suffix := range suffixes {
buf.WriteString(linePrefix)
buf.WriteString("PATTERN: ")
buf.WriteString(suffix.String())
buf.WriteString("\n")
suffix.Node.toBuffer(buf, newLinePrefix)
}
}
}
type SuffixKind int
// Note that order is important!
const (
// SuffixKindConstant matches a constant string
SuffixKindConstant = SuffixKind(iota)
// SuffixKindRegExp matches a regular expression
SuffixKindRegExp
// SuffixKindVariable matches everything until '/'
SuffixKindVariable
// SuffixKindEverything matches everything (until end-of-string)
SuffixKindEverything
)
// Suffix describes condition that
type Suffix struct {
Kind SuffixKind
Pattern string
// compiled regular expression
regExp *regexp.Regexp
// Next node
Node *Node
}
func EqualSuffix(a, b Suffix) bool {
return a.Kind == b.Kind && a.Pattern == b.Pattern
}
func (suffix Suffix) String() string {
switch suffix.Kind {
case SuffixKindConstant:
return suffix.Pattern
case SuffixKindVariable:
return "{_}"
case SuffixKindEverything:
return "{_*}"
default:
return "{_|" + suffix.Pattern + "}"
}
}
type SuffixList []Suffix
func (list SuffixList) Less(i, j int) bool {
a, b := list[i], list[j]
ak, bk := a.Kind, b.Kind
if ak < bk {
return true
} else if bk < ak {
return false
}
return a.Pattern > b.Pattern
}
func (list SuffixList) Len() int {
return len(list)
}
func (list SuffixList) Swap(i, j int) {
a, b := list[i], list[j]
list[i], list[j] = b, a
}
func (currentNode *Node) MustAdd(path string, value any, options *Options) {
node, err := currentNode.CreateNode(path, options)
if err != nil {
panic(err)
}
node.Value = value
}
func (currentNode *Node) Add(path string, value any, options *Options) error {
node, err := currentNode.CreateNode(path, options)
if err != nil {
return err
}
node.Value = value
return nil
}
func (currentNode *Node) CreateNode(path string, options *Options) (*Node, error) {
if options == nil {
options = DefaultOptions
}
for strings.HasSuffix(path, "/") {
path = path[:len(path)-1]
}
remaining := path
var variableNames []string
loop:
for {
//remaining = strings.TrimPrefix(remaining, "/")
if len(remaining) == 0 {
// This node is the right one
// Check whether another route already leads to this node
currentNode.VariableNames = variableNames
return currentNode, nil
}
suffix := Suffix{}
var i int
if strings.HasPrefix(remaining, "/") {
remaining = remaining[1:]
suffix.Kind = SuffixKindConstant
suffix.Pattern = "/"
} else {
i = strings.IndexAny(remaining, "/{")
if i < 0 {
i = len(remaining)
}
if i > 0 {
// Constant string pattern
suffix.Kind = SuffixKindConstant
suffix.Pattern = remaining[:i]
remaining = remaining[i:]
} else if remaining[0] == '{' {
// This is probably a variable
suffix.Kind = SuffixKindVariable
// Find variable name
i := strings.IndexByte(remaining, '}')
if i < 0 {
return nil, fmt.Errorf("missing '}' in: %s", path)
}
variableName := strings.TrimSpace(remaining[1:i])
remaining = remaining[i+1:]
if options.SupportRegExp {
// See if it has regular expression
i = strings.IndexByte(variableName, '|')
if i >= 0 {
suffix.Kind = SuffixKindRegExp
suffix.Pattern = strings.TrimSpace(variableName[i+1:])
variableName = strings.TrimSpace(variableName[:i])
}
}
if suffix.Kind == SuffixKindVariable && options.SupportWildcard {
if strings.HasSuffix(variableName, "*") {
suffix.Kind = SuffixKindEverything
}
}
variableNames = append(variableNames, variableName)
}
}
// Find existing matcher
for _, existing := range currentNode.Suffixes {
if EqualSuffix(existing, suffix) {
currentNode = existing.Node
continue loop
}
}
// Compile regular expression
if suffix.Kind == SuffixKindRegExp {
regExp, err := regexp.Compile(suffix.Pattern)
if err != nil {
return nil, fmt.Errorf("invalid regular expression in: %s", path)
}
suffix.regExp = regExp
}
// Create new node
newNode := &Node{}
suffix.Node = newNode
currentNode.Suffixes = append(currentNode.Suffixes, suffix)
sort.Sort(currentNode.Suffixes)
currentNode = newNode
continue loop
}
}
func (currentNode *Node) Match(path string) (*Node, []string) {
for strings.HasSuffix(path, "/") {
path = path[:len(path)-1]
}
variableValues := make([]string, 0, 8)
return currentNode.matchRemaining(path, variableValues)
}
func (currentNode *Node) matchRemaining(remaining string, paramValues []string) (*Node, []string) {
// Check if this node matches
if len(remaining) == 0 && currentNode.Value != nil {
return currentNode, paramValues
}
// See if any suffix matches
for _, suffix := range currentNode.Suffixes {
var resultNode *Node
var resultValues []string
switch suffix.Kind {
case SuffixKindConstant:
pattern := suffix.Pattern
if strings.HasPrefix(remaining, pattern) {
newRemaining := remaining[len(pattern):]
resultNode, resultValues = suffix.Node.matchRemaining(newRemaining, paramValues)
} else if len(remaining) == 0 && pattern == "/" {
resultNode, resultValues = suffix.Node.matchRemaining(remaining, paramValues)
}
case SuffixKindVariable:
i := strings.IndexByte(remaining, '/')
if i < 0 {
i = len(remaining)
}
newParamValues := append(paramValues, remaining[:i])
newRemaining := remaining[i:]
resultNode, resultValues = suffix.Node.matchRemaining(newRemaining, newParamValues)
case SuffixKindEverything:
newParamValues := append(paramValues, remaining)
resultNode, resultValues = suffix.Node, newParamValues
case SuffixKindRegExp:
i := strings.IndexByte(remaining, '/')
if i < 0 {
i = len(remaining)
}
paramValue := remaining[:i]
regExp := suffix.regExp
if regExp.MatchString(paramValue) {
matches := regExp.FindStringSubmatch(paramValue)
if len(matches) > 1 {
paramValue = matches[1]
}
newParamValues := append(paramValues, paramValue)
newRemaining := remaining[i:]
resultNode, resultValues = suffix.Node.matchRemaining(newRemaining, newParamValues)
}
}
if resultNode != nil && resultNode.Value != nil {
// This suffix matched
return resultNode, resultValues
}
}
// No suffix matched
return nil, nil
}
+164
View File
@@ -0,0 +1,164 @@
// Package legacy implements a router.
//
// It differs from the gorilla/mux router:
// * it provides granular errors: "path not found", "method not allowed", "variable missing from path"
// * it does not handle matching routes with extensions (e.g. /books/{id}.json)
// * it handles path patterns with a different syntax (e.g. /params/{x}/{y}/{z.*})
package legacy
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/legacy/pathpattern"
)
// Routers maps a HTTP request to a Router.
type Routers []*Router
// FindRoute extracts the route and parameters of an http.Request
func (rs Routers) FindRoute(req *http.Request) (routers.Router, *routers.Route, map[string]string, error) {
for _, router := range rs {
// Skip routers that have DO NOT have servers
if len(router.doc.Servers) == 0 {
continue
}
route, pathParams, err := router.FindRoute(req)
if err == nil {
return router, route, pathParams, nil
}
}
for _, router := range rs {
// Skip routers that DO have servers
if len(router.doc.Servers) > 0 {
continue
}
route, pathParams, err := router.FindRoute(req)
if err == nil {
return router, route, pathParams, nil
}
}
return nil, nil, nil, &routers.RouteError{
Reason: "none of the routers match",
}
}
// Router maps a HTTP request to an OpenAPI operation.
type Router struct {
doc *openapi3.T
pathNode *pathpattern.Node
}
// NewRouter creates a new router.
//
// If the given OpenAPIv3 document has servers, router will use them.
// All operations of the document will be added to the router.
func NewRouter(doc *openapi3.T, opts ...openapi3.ValidationOption) (routers.Router, error) {
if err := doc.Validate(context.Background(), opts...); err != nil {
return nil, fmt.Errorf("validating OpenAPI failed: %w", err)
}
router := &Router{doc: doc}
root := router.node()
for path, pathItem := range doc.Paths.Map() {
for method, operation := range pathItem.Operations() {
method = strings.ToUpper(method)
if err := root.Add(method+" "+path, &routers.Route{
Spec: doc,
Path: path,
PathItem: pathItem,
Method: method,
Operation: operation,
}, nil); err != nil {
return nil, err
}
}
}
return router, nil
}
// AddRoute adds a route in the router.
func (router *Router) AddRoute(route *routers.Route) error {
method := route.Method
if method == "" {
return errors.New("route is missing method")
}
method = strings.ToUpper(method)
path := route.Path
if path == "" {
return errors.New("route is missing path")
}
return router.node().Add(method+" "+path, router, nil)
}
func (router *Router) node() *pathpattern.Node {
root := router.pathNode
if root == nil {
root = &pathpattern.Node{}
router.pathNode = root
}
return root
}
// FindRoute extracts the route and parameters of an http.Request
func (router *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string, error) {
method, url := req.Method, req.URL
doc := router.doc
// Get server
servers := doc.Servers
var server *openapi3.Server
var remainingPath string
var pathParams map[string]string
if len(servers) == 0 {
remainingPath = url.Path
} else {
var paramValues []string
server, paramValues, remainingPath = servers.MatchURL(url)
if server == nil {
return nil, nil, &routers.RouteError{
Reason: routers.ErrPathNotFound.Error(),
}
}
pathParams = make(map[string]string)
paramNames, err := server.ParameterNames()
if err != nil {
return nil, nil, err
}
for i, value := range paramValues {
name := paramNames[i]
pathParams[name] = value
}
}
// Get PathItem
root := router.node()
var route *routers.Route
node, paramValues := root.Match(method + " " + remainingPath)
if node != nil {
route, _ = node.Value.(*routers.Route)
}
if route == nil {
pathItem := doc.Paths.Value(remainingPath)
if pathItem == nil {
return nil, nil, &routers.RouteError{Reason: routers.ErrPathNotFound.Error()}
}
if pathItem.GetOperation(method) == nil {
return nil, nil, &routers.RouteError{Reason: routers.ErrMethodNotAllowed.Error()}
}
}
if pathParams == nil {
pathParams = make(map[string]string, len(paramValues))
}
paramKeys := node.VariableNames
for i, value := range paramValues {
key := strings.TrimSuffix(paramKeys[i], "*")
pathParams[key] = value
}
return route, pathParams, nil
}
+42
View File
@@ -0,0 +1,42 @@
package routers
import (
"net/http"
"github.com/getkin/kin-openapi/openapi3"
)
// Router helps link http.Request.s and an OpenAPIv3 spec
type Router interface {
// FindRoute matches an HTTP request with the operation it resolves to.
// Hosts are matched from the OpenAPIv3 servers key.
//
// If you experience ErrPathNotFound and have localhost hosts specified as your servers,
// turning these server URLs as relative (leaving only the path) should resolve this.
//
// See openapi3filter for example uses with request and response validation.
FindRoute(req *http.Request) (route *Route, pathParams map[string]string, err error)
}
// Route describes the operation an http.Request can match
type Route struct {
Spec *openapi3.T
Server *openapi3.Server
Path string
PathItem *openapi3.PathItem
Method string
Operation *openapi3.Operation
}
// ErrPathNotFound is returned when no route match is found
var ErrPathNotFound error = &RouteError{"no matching operation was found"}
// ErrMethodNotAllowed is returned when no method of the matched route matches
var ErrMethodNotAllowed error = &RouteError{"method not allowed"}
// RouteError describes Router errors
type RouteError struct {
Reason string
}
func (e *RouteError) Error() string { return e.Reason }