Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

openapi3: add support for number and integer format validators #976

Merged
merged 7 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions .github/docs/openapi3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ const (
// FormatOfStringForEmail pattern catches only some suspiciously wrong-looking email addresses.
// Use DefineStringFormat(...) if you need something stricter.
FormatOfStringForEmail = `^[^@]+@[^@<>",\s]+$`

// FormatOfStringByte is a regexp for base64-encoded characters, for example, "U3dhZ2dlciByb2Nrcw=="
FormatOfStringByte = `(^$|^[a-zA-Z0-9+/\-_]*=*$)`

// FormatOfStringDate is a RFC3339 date format regexp, for example "2017-07-21".
FormatOfStringDate = `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)$`

// FormatOfStringDateTime is a RFC3339 date-time format regexp, for example "2017-07-21T17:32:28Z".
FormatOfStringDateTime = `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`
)
const (
SerializationSimple = "simple"
Expand Down Expand Up @@ -65,6 +74,14 @@ var (
// ErrSchemaInputInf may be returned when validating a number
ErrSchemaInputInf = errors.New("floating point Inf is not allowed")
)
var (
// SchemaStringFormats is a map of custom string format validators.
SchemaStringFormats = make(map[string]StringFormatValidator)
// SchemaNumberFormats is a map of custom number format validators.
SchemaNumberFormats = make(map[string]NumberFormatValidator)
// SchemaIntegerFormats is a map of custom integer format validators.
SchemaIntegerFormats = make(map[string]IntegerFormatValidator)
)
var DefaultReadFromURI = URIMapCache(ReadFromURIs(ReadFromHTTP(http.DefaultClient), ReadFromFile))
DefaultReadFromURI returns a caching ReadFromURIFunc which can read remote
HTTP URIs and local file URIs.
Expand All @@ -73,9 +90,6 @@ var ErrURINotSupported = errors.New("unsupported URI")
ErrURINotSupported indicates the ReadFromURIFunc does not know how to handle
a given URI.

var SchemaStringFormats = make(map[string]Format, 4)
SchemaStringFormats allows for validating string formats


FUNCTIONS

Expand Down Expand Up @@ -105,12 +119,27 @@ func DefineIPv4Format()
func DefineIPv6Format()
DefineIPv6Format opts in ipv6 format validation on top of OAS 3 spec

func DefineIntegerFormatValidator(name string, validator IntegerFormatValidator)
DefineIntegerFormatValidator defines a custom format validator for a given
integer format.

func DefineNumberFormatValidator(name string, validator NumberFormatValidator)
DefineNumberFormatValidator defines a custom format validator for a given
number format.

func DefineStringFormat(name string, pattern string)
DefineStringFormat defines a new regexp pattern for a given format
DefineStringFormat defines a regexp pattern for a given string
format Deprecated: Use openapi3.DefineStringFormatValidator(name,
NewRegexpFormatValidator(pattern)) instead.

func DefineStringFormatCallback(name string, callback FormatCallback)
DefineStringFormatCallback adds a validation function for a specific schema
format entry
func DefineStringFormatCallback(name string, callback func(string) error)
DefineStringFormatCallback defines a callback function for a given
string format Deprecated: Use openapi3.DefineStringFormatValidator(name,
NewCallbackValidator(fn)) instead.

func DefineStringFormatValidator(name string, validator StringFormatValidator)
DefineStringFormatValidator defines a custom format validator for a given
string format.

func Float64Ptr(value float64) *float64
Float64Ptr is a helper for defining OpenAPI schemas.
Expand Down Expand Up @@ -512,14 +541,22 @@ func (e *ExternalDocs) Validate(ctx context.Context, opts ...ValidationOption) e
Validate returns an error if ExternalDocs does not comply with the OpenAPI
spec.

type Format struct {
// Has unexported fields.
type FormatValidator[T any] interface {
Validate(value T) error
}
Format represents a format validator registered by either DefineStringFormat
or DefineStringFormatCallback
FormatValidator is an interface for custom format validators.

func NewCallbackValidator[T any](fn func(T) error) FormatValidator[T]
NewCallbackValidator creates a new FormatValidator that uses a callback
function to validate the value.

func NewIPValidator(isIPv4 bool) FormatValidator[string]
NewIPValidator creates a new FormatValidator that validates the value is an
IP address.

type FormatCallback func(value string) error
FormatCallback performs custom checks on exotic formats
func NewRangeFormatValidator[T int64 | float64](min, max T) FormatValidator[T]
NewRangeFormatValidator creates a new FormatValidator that validates the
value is within a given range.

type Header struct {
Parameter
Expand Down Expand Up @@ -617,6 +654,9 @@ func (info *Info) UnmarshalJSON(data []byte) error
func (info *Info) Validate(ctx context.Context, opts ...ValidationOption) error
Validate returns an error if Info does not comply with the OpenAPI spec.

type IntegerFormatValidator = FormatValidator[int64]
IntegerFormatValidator is a type alias for FormatValidator[int64]

type License struct {
Extensions map[string]any `json:"-" yaml:"-"`

Expand Down Expand Up @@ -821,6 +861,9 @@ func WithName(name string, response *Response) NewResponsesOption
func WithStatus(status int, responseRef *ResponseRef) NewResponsesOption
WithStatus adds a status code keyed ResponseRef

type NumberFormatValidator = FormatValidator[float64]
NumberFormatValidator is a type alias for FormatValidator[float64]

type OAuthFlow struct {
Extensions map[string]any `json:"-" yaml:"-"`

Expand Down Expand Up @@ -1917,6 +1960,13 @@ type SliceUniqueItemsChecker func(items []any) bool
SliceUniqueItemsChecker is an function used to check if an given slice have
unique items.

type StringFormatValidator = FormatValidator[string]
StringFormatValidator is a type alias for FormatValidator[string]

func NewRegexpFormatValidator(pattern string) StringFormatValidator
NewRegexpFormatValidator creates a new FormatValidator that uses a regular
expression to validate the value.

type T struct {
Extensions map[string]any `json:"-" yaml:"-"`

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ for _, path := range doc.Paths.InMatchingOrder() {
* `openapi3.CircularReferenceError` and `openapi3.CircularReferenceCounter` are removed. `openapi3.Loader` now implements reference backtracking, so any kind of circular references should be properly resolved.
* `InternalizeRefs` now takes a refNameResolver that has access to `openapi3.T` and more properties of the reference needing resolving.
* The `DefaultRefNameResolver` has been updated, choosing names that will be less likely to collide with each other. Because of this internalized specs will likely change slightly.
* `openapi3.Format` and `openapi3.FormatCallback` are removed and the type of `openapi3.SchemaStringFormats` has changed.

### v0.125.0
* The `openapi3filter.ErrFunc` and `openapi3filter.LogFunc` func types now take the validated request's context as first argument.
Expand Down
4 changes: 2 additions & 2 deletions openapi3/issue735_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ type testCase struct {
}

func TestIssue735(t *testing.T) {
DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122)
DefineStringFormat("email", FormatOfStringForEmail)
DefineStringFormatValidator("uuid", NewRegexpFormatValidator(FormatOfStringForUUIDOfRFC4122))
DefineStringFormatValidator("email", NewRegexpFormatValidator(FormatOfStringForEmail))
DefineIPv4Format()
DefineIPv6Format()

Expand Down
109 changes: 56 additions & 53 deletions openapi3/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,6 @@ const (
TypeObject = "object"
TypeString = "string"
TypeNull = "null"

// constants for integer formats
formatMinInt32 = float64(math.MinInt32)
formatMaxInt32 = float64(math.MaxInt32)
formatMinInt64 = float64(math.MinInt64)
formatMaxInt64 = float64(math.MaxInt64)
)

var (
Expand Down Expand Up @@ -988,7 +982,7 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema,
switch format {
case "float", "double":
default:
if validationOpts.schemaFormatValidationEnabled {
if _, ok := SchemaNumberFormats[format]; !ok && validationOpts.schemaFormatValidationEnabled {
return stack, unsupportedFormat(format)
}
}
Expand All @@ -998,7 +992,7 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema,
switch format {
case "int32", "int64":
default:
if validationOpts.schemaFormatValidationEnabled {
if _, ok := SchemaIntegerFormats[format]; !ok && validationOpts.schemaFormatValidationEnabled {
return stack, unsupportedFormat(format)
}
}
Expand All @@ -1019,7 +1013,6 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema,
// Defined in some other specification
case "email", "hostname", "ipv4", "ipv6", "uri", "uri-reference":
default:
// Try to check for custom defined formats
if _, ok := SchemaStringFormats[format]; !ok && validationOpts.schemaFormatValidationEnabled {
return stack, unsupportedFormat(format)
}
Expand Down Expand Up @@ -1523,39 +1516,56 @@ func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value
}

// formats
if requireInteger && schema.Format != "" {
formatMin := float64(0)
formatMax := float64(0)
switch schema.Format {
case "int32":
formatMin = formatMinInt32
formatMax = formatMaxInt32
case "int64":
formatMin = formatMinInt64
formatMax = formatMaxInt64
default:
if settings.formatValidationEnabled {
return unsupportedFormat(schema.Format)
}
}
if formatMin != 0 && formatMax != 0 && !(formatMin <= value && value <= formatMax) {
if settings.failfast {
return errSchema
}
err := &SchemaError{
Value: value,
Schema: schema,
SchemaField: "format",
Reason: fmt.Sprintf("number must be an %s", schema.Format),
customizeMessageError: settings.customizeMessageError,
var formatStrErr string
var formatErr error
format := schema.Format
if format != "" {
if requireInteger {
if f, ok := SchemaIntegerFormats[format]; ok {
if err := f.Validate(int64(value)); err != nil {
var reason string
schemaErr := &SchemaError{}
if errors.As(err, &schemaErr) {
reason = schemaErr.Reason
} else {
reason = err.Error()
}
formatStrErr = fmt.Sprintf(`integer doesn't match the format %q (%v)`, format, reason)
formatErr = fmt.Errorf("integer doesn't match the format %q: %w", format, err)
}
}
if !settings.multiError {
return err
} else {
if f, ok := SchemaNumberFormats[format]; ok {
if err := f.Validate(value); err != nil {
var reason string
schemaErr := &SchemaError{}
if errors.As(err, &schemaErr) {
reason = schemaErr.Reason
} else {
reason = err.Error()
}
formatStrErr = fmt.Sprintf(`number doesn't match the format %q (%v)`, format, reason)
formatErr = fmt.Errorf("number doesn't match the format %q: %w", format, err)
}
}
me = append(me, err)
}
}

if formatStrErr != "" || formatErr != nil {
err := &SchemaError{
Value: value,
Schema: schema,
SchemaField: "format",
Reason: formatStrErr,
Origin: formatErr,
customizeMessageError: settings.customizeMessageError,
}
if !settings.multiError {
return err
}
me = append(me, err)
}

// "exclusiveMinimum"
if v := schema.ExclusiveMin; v && !(*schema.Min < value) {
if settings.failfast {
Expand Down Expand Up @@ -1749,23 +1759,16 @@ func (schema *Schema) visitJSONString(settings *schemaValidationSettings, value
var formatErr error
if format := schema.Format; format != "" {
if f, ok := SchemaStringFormats[format]; ok {
switch {
case f.regexp != nil && f.callback == nil:
if cp := f.regexp; !cp.MatchString(value) {
formatStrErr = fmt.Sprintf(`string doesn't match the format %q (regular expression "%s")`, format, cp.String())
}
case f.regexp == nil && f.callback != nil:
if err := f.callback(value); err != nil {
schemaErr := &SchemaError{}
if errors.As(err, &schemaErr) {
formatStrErr = fmt.Sprintf(`string doesn't match the format %q (%s)`, format, schemaErr.Reason)
} else {
formatStrErr = fmt.Sprintf(`string doesn't match the format %q (%v)`, format, err)
}
formatErr = err
if err := f.Validate(value); err != nil {
var reason string
schemaErr := &SchemaError{}
if errors.As(err, &schemaErr) {
reason = schemaErr.Reason
} else {
reason = err.Error()
}
default:
formatStrErr = fmt.Sprintf("corrupted entry %q in SchemaStringFormats", format)
formatStrErr = fmt.Sprintf(`string doesn't match the format %q (%v)`, format, reason)
formatErr = fmt.Errorf("string doesn't match the format %q: %w", format, err)
}
}
}
Expand Down
Loading
Loading