Skip to content

Commit

Permalink
openapi3: add support for number and integer format validators (#976)
Browse files Browse the repository at this point in the history
* feat(format): add support for number and integer format validators

* update docs

* add deprecation notice

* add breaking changes notice

* minimize error message changes

* remove obvious comments

* fix string quotation
  • Loading branch information
AnatolyRugalev authored Jul 5, 2024
1 parent 4b53bf6 commit 4144c56
Show file tree
Hide file tree
Showing 8 changed files with 342 additions and 139 deletions.
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

0 comments on commit 4144c56

Please sign in to comment.