diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ac25b13da..5d1938176 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -77,6 +77,18 @@ jobs: if: matrix.go != '1.14' + - run: go test -tags xeipuuv ./... + - run: go test -tags xeipuuv -v -run TestRaceyPatternSchema -race ./... + env: + CGO_ENABLED: '1' + - run: | + cp openapi3/testdata/load_with_go_embed_test.go openapi3/ + cat go.mod | sed 's%go 1.14%go 1.16%' >gomod && mv gomod go.mod + go test -tags xeipuuv ./... + if: matrix.go != '1.14' + + + - if: runner.os == 'Linux' name: Errors must not be capitalized https://github.com/golang/go/wiki/CodeReviewComments#error-strings run: | diff --git a/go.mod b/go.mod index f84f470c1..563d62ccb 100644 --- a/go.mod +++ b/go.mod @@ -7,5 +7,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.5 github.com/gorilla/mux v1.8.0 github.com/stretchr/testify v1.5.1 + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index 2b289d716..232caee76 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/openapi3/race_test.go b/openapi3/race_test.go index 4ac31c38e..2237c2b05 100644 --- a/openapi3/race_test.go +++ b/openapi3/race_test.go @@ -18,7 +18,7 @@ func TestRaceyPatternSchema(t *testing.T) { require.NoError(t, err) visit := func() { - err := schema.VisitJSONString("test") + err := schema.VisitData(nil, "test") require.NoError(t, err) } diff --git a/openapi3/schema.go b/openapi3/schema.go index 582cbe2ff..d2dc3d203 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -1,6 +1,8 @@ package openapi3 import ( + "context" + "errors" "fmt" "regexp" "strconv" @@ -9,6 +11,8 @@ import ( "github.com/go-openapi/jsonpointer" ) +var errSchema = errors.New("input does not match the schema") + // Float64Ptr is a helper for defining OpenAPI schemas. func Float64Ptr(value float64) *float64 { return &value @@ -131,6 +135,11 @@ func (schema *Schema) VisitData(doc *T, data interface{}, opts ...SchemaValidati return schema.visitData(doc, data, opts...) } +// VisitJSON validates given data against schema only. +func (schema *Schema) VisitJSON(data interface{}, opts ...SchemaValidationOption) error { + return schema.VisitData(nil, data, opts...) +} + func NewSchema() *Schema { return &Schema{} } @@ -559,140 +568,6 @@ func (schema *Schema) IsEmpty() bool { return true } -// func (schema *Schema) expectedType(settings *schemaValidationSettings, typ string) error { -// if settings.failfast { -// return errSchema -// } -// return &SchemaError{ -// Value: typ, -// Schema: schema, -// SchemaField: "type", -// Reason: "Field must be set to " + schema.Type + " or not be present", -// } -// } - -// func (schema *Schema) compilePattern() (err error) { -// if schema.compiledPattern, err = regexp.Compile(schema.Pattern); err != nil { -// return &SchemaError{ -// Schema: schema, -// SchemaField: "pattern", -// Reason: fmt.Sprintf("cannot compile pattern %q: %v", schema.Pattern, err), -// } -// } -// return nil -// } - -// type SchemaError struct { -// Value interface{} -// reversePath []string -// Schema *Schema -// SchemaField string -// Reason string -// Origin error -// } - -// func markSchemaErrorKey(err error, key string) error { -// if v, ok := err.(*SchemaError); ok { -// v.reversePath = append(v.reversePath, key) -// return v -// } -// if v, ok := err.(MultiError); ok { -// for _, e := range v { -// _ = markSchemaErrorKey(e, key) -// } -// return v -// } -// return err -// } - -// func markSchemaErrorIndex(err error, index int) error { -// if v, ok := err.(*SchemaError); ok { -// v.reversePath = append(v.reversePath, strconv.FormatInt(int64(index), 10)) -// return v -// } -// if v, ok := err.(MultiError); ok { -// for _, e := range v { -// _ = markSchemaErrorIndex(e, index) -// } -// return v -// } -// return err -// } - -// func (err *SchemaError) JSONPointer() []string { -// reversePath := err.reversePath -// path := append([]string(nil), reversePath...) -// for left, right := 0, len(path)-1; left < right; left, right = left+1, right-1 { -// path[left], path[right] = path[right], path[left] -// } -// return path -// } - -// func (err *SchemaError) Error() string { -// if err.Origin != nil { -// return err.Origin.Error() -// } - -// buf := bytes.NewBuffer(make([]byte, 0, 256)) -// if len(err.reversePath) > 0 { -// buf.WriteString(`Error at "`) -// reversePath := err.reversePath -// for i := len(reversePath) - 1; i >= 0; i-- { -// buf.WriteByte('/') -// buf.WriteString(reversePath[i]) -// } -// buf.WriteString(`": `) -// } -// reason := err.Reason -// if reason == "" { -// buf.WriteString(`Doesn't match schema "`) -// buf.WriteString(err.SchemaField) -// buf.WriteString(`"`) -// } else { -// buf.WriteString(reason) -// } -// if !SchemaErrorDetailsDisabled { -// buf.WriteString("\nSchema:\n ") -// encoder := json.NewEncoder(buf) -// encoder.SetIndent(" ", " ") -// if err := encoder.Encode(err.Schema); err != nil { -// panic(err) -// } -// buf.WriteString("\nValue:\n ") -// if err := encoder.Encode(err.Value); err != nil { -// panic(err) -// } -// } -// return buf.String() -// } - -// func isSliceOfUniqueItems(xs []interface{}) bool { -// s := len(xs) -// m := make(map[string]struct{}, s) -// for _, x := range xs { -// // The input slice is coverted from a JSON string, there shall -// // have no error when covert it back. -// key, _ := json.Marshal(&x) -// m[string(key)] = struct{}{} -// } -// return s == len(m) -// } - -// // SliceUniqueItemsChecker is an function used to check if an given slice -// // have unique items. -// type SliceUniqueItemsChecker func(items []interface{}) bool - -// // By default using predefined func isSliceOfUniqueItems which make use of -// // json.Marshal to generate a key for map used to check if a given slice -// // have unique items. -// var sliceUniqueItemsChecker SliceUniqueItemsChecker = isSliceOfUniqueItems - -// // RegisterArrayUniqueItemsChecker is used to register a customized function -// // used to check if JSON array have unique items. -// func RegisterArrayUniqueItemsChecker(fn SliceUniqueItemsChecker) { -// sliceUniqueItemsChecker = fn -// } - -// func unsupportedFormat(format string) error { -// return fmt.Errorf("unsupported 'format' value %q", format) -// } +func (value *Schema) Validate(ctx context.Context) error { + return value.validate(ctx, []*Schema{}) +} diff --git a/openapi3/schema_formats.go b/openapi3/schema_formats.go index 1eb41509e..55dbf844c 100644 --- a/openapi3/schema_formats.go +++ b/openapi3/schema_formats.go @@ -1,3 +1,5 @@ +// +build legacy + package openapi3 import ( @@ -6,11 +8,6 @@ import ( "regexp" ) -const ( - // FormatOfStringForUUIDOfRFC4122 is an optional predefined format for UUID v1-v5 as specified by RFC4122 - FormatOfStringForUUIDOfRFC4122 = `^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` -) - //FormatCallback custom check on exotic formats type FormatCallback func(Val string) error @@ -103,3 +100,9 @@ func DefineIPv4Format() { func DefineIPv6Format() { DefineStringFormatCallback("ipv6", validateIPv6) } + +// DefineUUIDFormat defines a string format for UUID v1-v5 as specified by RFC4122 +func DefineUUIDFormat() { + const FormatOfStringForUUIDOfRFC4122 = `^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` + DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) +} diff --git a/openapi3/schema_formats_xeipuuv.go b/openapi3/schema_formats_xeipuuv.go new file mode 100644 index 000000000..7f246d999 --- /dev/null +++ b/openapi3/schema_formats_xeipuuv.go @@ -0,0 +1,52 @@ +// +build xeipuuv + +package openapi3 + +import ( + "regexp" + + "github.com/xeipuuv/gojsonschema" + // https://github.com/xeipuuv/gojsonschema/pull/297/files discriminator support +) + +func init() { + // gojsonschema.FormatCheckers = gojsonschema.FormatCheckerChain{} FIXME https://github.com/xeipuuv/gojsonschema/pull/326 + gojsonschema.FormatCheckers.Add("byte", byteFormatChecker{}) + gojsonschema.FormatCheckers.Add("date", gojsonschema.DateFormatChecker{}) + gojsonschema.FormatCheckers.Add("date-time", gojsonschema.DateTimeFormatChecker{}) +} + +type byteFormatChecker struct{} + +var _ gojsonschema.FormatChecker = (*byteFormatChecker)(nil) +var reByteFormatChecker = regexp.MustCompile(`(^$|^[a-zA-Z0-9+/\-_]*=*$)`) + +// IsFormat supports base64 and base64url. Padding ('=') is supported. +func (byteFormatChecker) IsFormat(input interface{}) bool { + asString, ok := input.(string) + if !ok { + return true + } + + return reByteFormatChecker.MatchString(asString) +} + +// DefineEmailFormat opts-in to checking email format (outside of OpenAPIv3 spec) +func DefineEmailFormat() { + gojsonschema.FormatCheckers.Add("email", gojsonschema.EmailFormatChecker{}) +} + +// DefineUUIDFormat opts-in to checking uuid format v1-v5 as specified by RFC4122 (outside of OpenAPIv3 spec) +func DefineUUIDFormat() { + gojsonschema.FormatCheckers.Add("uuid", gojsonschema.UUIDFormatChecker{}) +} + +// DefineIPv4Format opts in ipv4 format validation on top of OAS 3 spec +func DefineIPv4Format() { + gojsonschema.FormatCheckers.Add("ipv4", gojsonschema.IPV4FormatChecker{}) +} + +// DefineIPv6Format opts in ipv6 format validation on top of OAS 3 spec +func DefineIPv6Format() { + gojsonschema.FormatCheckers.Add("ipv6", gojsonschema.IPV6FormatChecker{}) +} diff --git a/openapi3/schema_from_openapi.go b/openapi3/schema_from_openapi.go new file mode 100644 index 000000000..25f0b9c17 --- /dev/null +++ b/openapi3/schema_from_openapi.go @@ -0,0 +1,147 @@ +package openapi3 + +type schemaJSON = map[string]interface{} +type schemasJSON = map[string]schemaJSON + +func (s *SchemaRef) fromOpenAPISchema(settings *schemaValidationSettings) (schema schemaJSON) { + if ref := s.Ref; ref != "" { + return schemaJSON{"$ref": ref} + } + return s.Value.fromOpenAPISchema(settings) +} + +func (s *Schema) fromOpenAPISchema(settings *schemaValidationSettings) (schema schemaJSON) { + schema = make(schemaJSON) + + if sEnum := s.Enum; len(sEnum) != 0 { + schema["enum"] = sEnum + } + + if sMinLength := s.MinLength; sMinLength != 0 { + schema["minLength"] = sMinLength + } + if sMaxLength := s.MaxLength; nil != sMaxLength { + schema["maxLength"] = *sMaxLength + } + + if sFormat := s.Format; sFormat != "" { + schema["format"] = sFormat + } + if sPattern := s.Pattern; sPattern != "" { + schema["pattern"] = sPattern + } + + if nil != s.Min { + schema["minimum"] = *s.Min + } + if nil != s.Max { + schema["maximum"] = *s.Max + } + if sExMin := s.ExclusiveMin; sExMin { + schema["exclusiveMinimum"] = sExMin + } + if sExMax := s.ExclusiveMax; sExMax { + schema["exclusiveMaximum"] = sExMax + } + if nil != s.MultipleOf { + schema["multipleOf"] = *s.MultipleOf + } + + if sUniq := s.UniqueItems; sUniq { + schema["uniqueItems"] = sUniq + } + if sMinItems := s.MinItems; sMinItems != 0 { + schema["minItems"] = sMinItems + } + if nil != s.MaxItems { + schema["maxItems"] = *s.MaxItems + } + if sItems := s.Items; nil != sItems { + if sItems.Value != nil && sItems.Value.IsEmpty() { + schema["items"] = []schemaJSON{} + } else { + schema["items"] = []schemaJSON{sItems.fromOpenAPISchema(settings)} + } + } + + if sMinProps := s.MinProps; sMinProps != 0 { + schema["minProperties"] = sMinProps + } + if nil != s.MaxProps { + schema["maxProperties"] = *s.MaxProps + } + + if sRequired := s.Required; len(sRequired) != 0 { + required := make([]string, 0, len(sRequired)) + for _, propName := range sRequired { + prop := s.Properties[propName] + switch { + case settings.asreq && prop != nil && prop.Value.ReadOnly: + case settings.asrep && prop != nil && prop.Value.WriteOnly: + default: + required = append(required, propName) + } + } + schema["required"] = required + } + + if count := len(s.Properties); count != 0 { + properties := make(schemasJSON, count) + for propName, prop := range s.Properties { + properties[propName] = prop.fromOpenAPISchema(settings) + } + schema["properties"] = properties + } + + if sAddProps := s.AdditionalPropertiesAllowed; sAddProps != nil { + // TODO: complete handling + schema["additionalProperties"] = sAddProps + } + + if sAllOf := s.AllOf; len(sAllOf) != 0 { + allOf := make([]schemaJSON, 0, len(sAllOf)) + for _, sOf := range sAllOf { + allOf = append(allOf, sOf.fromOpenAPISchema(settings)) + } + schema["allOf"] = allOf + } + if sAnyOf := s.AnyOf; len(sAnyOf) != 0 { + anyOf := make([]schemaJSON, 0, len(sAnyOf)) + for _, sOf := range sAnyOf { + anyOf = append(anyOf, sOf.fromOpenAPISchema(settings)) + } + schema["anyOf"] = anyOf + } + if sOneOf := s.OneOf; len(sOneOf) != 0 { + oneOf := make([]schemaJSON, 0, len(sOneOf)) + for _, sOf := range sOneOf { + oneOf = append(oneOf, sOf.fromOpenAPISchema(settings)) + } + schema["oneOf"] = oneOf + } + + if sType := s.Type; sType != "" { + schema["type"] = []string{s.Type} + } + + if sNot := s.Not; sNot != nil { + schema["not"] = sNot.fromOpenAPISchema(settings) + } + + if s.IsEmpty() { + schema = schemaJSON{"not": schemaJSON{"type": "null"}} + } + + if s.Nullable { + schema = schemaJSON{"anyOf": []schemaJSON{ + {"type": "null"}, + schema, + }} + } + + schema["$schema"] = "http://json-schema.org/draft-04/schema#" + //FIXME + //https://github.com/openapi-contrib/openapi-schema-to-json-schema/blob/45c080c38027c30652263b4cc44cd3534f5ccc1b/lib/converters/schema.js + //https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#schemaObject + return +} diff --git a/openapi3/schema_issue289_test.go b/openapi3/schema_issue289_test.go index 6ab6b63d5..7e21b7cd6 100644 --- a/openapi3/schema_issue289_test.go +++ b/openapi3/schema_issue289_test.go @@ -27,13 +27,21 @@ func TestIssue289(t *testing.T) { pattern: "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$" type: string openapi: "3.0.1" +info: + title: An API + version: v1 +paths: {} `) - s, err := NewLoader().LoadFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) - err = s.Components.Schemas["Server"].Value.VisitJSON(map[string]interface{}{ + err = doc.Validate(loader.Context) + require.NoError(t, err) + + err = doc.Components.Schemas["Server"].Value.VisitJSON(map[string]interface{}{ "name": "kin-openapi", "address": "127.0.0.1", }) - require.EqualError(t, err, ErrOneOfConflict.Error()) + require.Contains(t, err.Error(), "oneOf") } diff --git a/openapi3/schema_oneOf_test.go b/openapi3/schema_oneOf_test.go index 03fb670b1..670f1d8a0 100644 --- a/openapi3/schema_oneOf_test.go +++ b/openapi3/schema_oneOf_test.go @@ -79,40 +79,114 @@ var oneofNoDiscriminatorSpec = []byte(`components: - $ref: "#/components/schemas/Dog" `) -func TestVisitJSON_OneOf_MissingDiscriptorProperty(t *testing.T) { +func TestVisitData_OneOf_MissingDiscriptorProperty(t *testing.T) { s, err := NewLoader().LoadFromData(oneofSpec) require.NoError(t, err) - err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + err = s.Components.Schemas["Animal"].Value.VisitData(nil, map[string]interface{}{ "name": "snoopy", }) require.EqualError(t, err, "input does not contain the discriminator property") } -func TestVisitJSON_OneOf_MissingDiscriptorValue(t *testing.T) { +func TestVisitData_OneOf_MissingDiscriptorValue(t *testing.T) { s, err := NewLoader().LoadFromData(oneofSpec) require.NoError(t, err) - err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + err = s.Components.Schemas["Animal"].Value.VisitData(nil, map[string]interface{}{ "name": "snoopy", "$type": "snake", }) require.EqualError(t, err, "input does not contain a valid discriminator value") } -func TestVisitJSON_OneOf_MissingField(t *testing.T) { +func TestVisitData_OneOf_MissingField(t *testing.T) { s, err := NewLoader().LoadFromData(oneofSpec) require.NoError(t, err) - err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + err = s.Components.Schemas["Animal"].Value.VisitData(nil, map[string]interface{}{ "name": "snoopy", "$type": "dog", }) - require.EqualError(t, err, "Error at \"/barks\": property \"barks\" is missing\nSchema:\n {\n \"properties\": {\n \"$type\": {\n \"enum\": [\n \"dog\"\n ],\n \"type\": \"string\"\n },\n \"barks\": {\n \"type\": \"boolean\"\n },\n \"name\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"barks\",\n \"$type\"\n ],\n \"type\": \"object\"\n }\n\nValue:\n {\n \"$type\": \"dog\",\n \"name\": \"snoopy\"\n }\n") + require.EqualError(t, err, `Error at "/barks": property "barks" is missing +Schema: + { + "properties": { + "$type": { + "enum": [ + "dog" + ], + "type": "string" + }, + "barks": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "barks", + "$type" + ], + "type": "object" + } + +Value: + { + "$type": "dog", + "name": "snoopy" + } +`) } -func TestVisitJSON_OneOf_NoDiscriptor_MissingField(t *testing.T) { +func TestVisitData_OneOf_NoDiscriptor_MissingField(t *testing.T) { s, err := NewLoader().LoadFromData(oneofNoDiscriminatorSpec) require.NoError(t, err) - err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + err = s.Components.Schemas["Animal"].Value.VisitData(nil, map[string]interface{}{ "name": "snoopy", }) - require.EqualError(t, err, "doesn't match schema due to: Error at \"/scratches\": property \"scratches\" is missing\nSchema:\n {\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"scratches\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"name\",\n \"scratches\"\n ],\n \"type\": \"object\"\n }\n\nValue:\n {\n \"name\": \"snoopy\"\n }\n Or Error at \"/barks\": property \"barks\" is missing\nSchema:\n {\n \"properties\": {\n \"barks\": {\n \"type\": \"boolean\"\n },\n \"name\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"barks\"\n ],\n \"type\": \"object\"\n }\n\nValue:\n {\n \"name\": \"snoopy\"\n }\n") + require.EqualError(t, err, `doesn't match schema due to: Error at "/scratches": property "scratches" is missing +Schema: + { + "properties": { + "name": { + "type": "string" + }, + "scratches": { + "type": "boolean" + } + }, + "required": [ + "name", + "scratches" + ], + "type": "object" + } + +Value: + { + "name": "snoopy" + } + Or Error at "/barks": property "barks" is missing +Schema: + { + "properties": { + "barks": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "barks" + ], + "type": "object" + } + +Value: + { + "name": "snoopy" + } +`) } diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index f724f08e2..9651dbb05 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -22,7 +22,7 @@ type schemaExample struct { } func TestSchemas(t *testing.T) { - DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) + DefineUUIDFormat() for _, example := range schemaExamples { t.Run(example.Title, testSchema(t, example)) } @@ -1196,6 +1196,10 @@ var schemaMultiErrorExamples = []schemaMultiErrorExample{ func TestIssue283(t *testing.T) { const api = ` openapi: "3.0.1" +info: + title: An API + version: v1 +paths: {} components: schemas: Test: @@ -1207,15 +1211,17 @@ components: type: boolean type: object ` - data := map[string]interface{}{ + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(api)) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + err = doc.Components.Schemas["Test"].Value.VisitData(doc, map[string]interface{}{ "name": "kin-openapi", "ownerName": true, - } - s, err := NewLoader().LoadFromData([]byte(api)) - require.NoError(t, err) - require.NotNil(t, s) - err = s.Components.Schemas["Test"].Value.VisitJSON(data) - require.NotNil(t, err) + }) require.NotEqual(t, errSchema, err) require.Contains(t, err.Error(), `Error at "/ownerName": Doesn't match schema "not"`) } diff --git a/openapi3/schemas_legacy.go b/openapi3/schemas_legacy.go index bcc0657c7..eab43f8f5 100644 --- a/openapi3/schemas_legacy.go +++ b/openapi3/schemas_legacy.go @@ -22,8 +22,6 @@ var ( //SchemaFormatValidationDisabled disables validation of schema type formats. SchemaFormatValidationDisabled = false - errSchema = errors.New("input does not match the schema") - // ErrOneOfConflict is the SchemaError Origin when data matches more than one oneOf schema ErrOneOfConflict = errors.New("input matches more than one oneOf schemas") @@ -40,11 +38,8 @@ func (doc *T) compileSchemas(settings *schemaValidationSettings) (err error) { } func (schema *Schema) visitData(doc *T, data interface{}, opts ...SchemaValidationOption) (err error) { - return schema.VisitJSON(data, opts...) -} - -func (value *Schema) Validate(ctx context.Context) error { - return value.validate(ctx, []*Schema{}) + settings := newSchemaValidationSettings(opts...) + return schema.visitJSON(settings, data) } func (schema *Schema) validate(ctx context.Context, stack []*Schema) (err error) { @@ -218,11 +213,6 @@ func (schema *Schema) IsMatchingJSONObject(value map[string]interface{}) bool { return schema.visitJSON(settings, value) == nil } -func (schema *Schema) VisitJSON(value interface{}, opts ...SchemaValidationOption) error { - settings := newSchemaValidationSettings(opts...) - return schema.visitJSON(settings, value) -} - func (schema *Schema) visitJSON(settings *schemaValidationSettings, value interface{}) (err error) { switch value := value.(type) { case nil: diff --git a/openapi3/schemas_xeipuuv.go b/openapi3/schemas_xeipuuv.go new file mode 100644 index 000000000..f8a8bc33e --- /dev/null +++ b/openapi3/schemas_xeipuuv.go @@ -0,0 +1,283 @@ +// +build xeipuuv + +package openapi3 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/xeipuuv/gojsonschema" +) + +type schemaLoader = *gojsonschema.SchemaLoader + +func (doc *T) compileSchemas(settings *schemaValidationSettings) (err error) { + docSchemas := doc.Components.Schemas + schemas := make(schemasJSON, len(docSchemas)) + for name, docSchema := range docSchemas { + schemas[name] = docSchema.Value.fromOpenAPISchema(settings) + } + //FIXME merge loops + refd := gojsonschema.NewSchemaLoader() + for name, schema := range schemas { + absRef := "#/components/schemas/" + name + sl := gojsonschema.NewGoLoader(schema) + if err = refd.AddSchema(absRef, sl); err != nil { + return + } + } + + switch { + case settings.asreq: + doc.refdAsReq = refd + case settings.asrep: + doc.refdAsRep = refd + default: + doc.refd = refd + } + return +} + +func (schema *Schema) visitData(doc *T, data interface{}, opts ...SchemaValidationOption) (err error) { + settings := newSchemaValidationSettings(opts...) + ls := gojsonschema.NewGoLoader(schema.fromOpenAPISchema(settings)) + ld := gojsonschema.NewGoLoader(data) + + var res *gojsonschema.Result + if doc != nil { + if doc.refdAsReq == nil || doc.refdAsRep == nil || doc.refd == nil { + panic(`func (*T) CompileSchemas() error must be called first`) + } + var whole *gojsonschema.Schema + switch { + case settings.asreq: + whole, err = doc.refdAsReq.Compile(ls) + case settings.asrep: + whole, err = doc.refdAsRep.Compile(ls) + default: + whole, err = doc.refd.Compile(ls) + } + if err != nil { + return + } + res, err = whole.Validate(ld) + } else { + res, err = gojsonschema.Validate(ls, ld) + } + if err != nil { + return + } + + if !res.Valid() { + err := SchemaValidationError(res.Errors()) + if settings.multiError { + return err.asMultiError() + } + return err + } + return +} + +func (schema *Schema) validate(ctx context.Context, stack []*Schema) (err error) { + for _, existing := range stack { + if existing == schema { + return + } + } + stack = append(stack, schema) + + if schema.ReadOnly && schema.WriteOnly { + return errors.New("a property MUST NOT be marked as both readOnly and writeOnly being true") + } + + for _, item := range schema.OneOf { + v := item.Value + if v == nil { + return foundUnresolvedRef(item.Ref) + } + if err = v.validate(ctx, stack); err == nil { + return + } + } + + for _, item := range schema.AnyOf { + v := item.Value + if v == nil { + return foundUnresolvedRef(item.Ref) + } + if err = v.validate(ctx, stack); err != nil { + return + } + } + + for _, item := range schema.AllOf { + v := item.Value + if v == nil { + return foundUnresolvedRef(item.Ref) + } + if err = v.validate(ctx, stack); err != nil { + return + } + } + + if ref := schema.Not; ref != nil { + v := ref.Value + if v == nil { + return foundUnresolvedRef(ref.Ref) + } + if err = v.validate(ctx, stack); err != nil { + return + } + } + + schemaType := schema.Type + // NOTE: any format is valid, as per: + // > However, to support documentation needs, the format property is an open string-valued property, and can have any value. + switch schemaType { + case "": + case "boolean": + case "number": + case "integer": + case "string": + case "array": + if schema.Items == nil { + return errors.New("when schema type is 'array', schema 'items' must be non-null") + } + case "object": + default: + return fmt.Errorf("unsupported 'type' value %q", schemaType) + } + + if pattern := schema.Pattern; pattern != "" { + if _, err = regexp.Compile(pattern); err != nil { + return &SchemaError{ + Schema: schema, + SchemaField: "pattern", + Reason: fmt.Sprintf("cannot compile pattern %q: %v", pattern, err), + } + } + } + + if ref := schema.Items; ref != nil { + v := ref.Value + if v == nil { + return foundUnresolvedRef(ref.Ref) + } + if err = v.validate(ctx, stack); err != nil { + return + } + } + + for _, ref := range schema.Properties { + v := ref.Value + if v == nil { + return foundUnresolvedRef(ref.Ref) + } + if err = v.validate(ctx, stack); err != nil { + return + } + } + + if ref := schema.AdditionalProperties; ref != nil { + v := ref.Value + if v == nil { + return foundUnresolvedRef(ref.Ref) + } + if err = v.validate(ctx, stack); err != nil { + return + } + } + + return +} + +// SchemaValidationError is a collection of errors +type SchemaValidationError []gojsonschema.ResultError + +var _ error = (*SchemaValidationError)(nil) + +func (e SchemaValidationError) Error() string { + var buff strings.Builder + for i, re := range []gojsonschema.ResultError(e) { + if i != 0 { + buff.WriteString("\n") + } + buff.WriteString(re.String()) + } + return buff.String() +} + +// Errors unwraps into much detailed errors. +// See https://pkg.go.dev/github.com/xeipuuv/gojsonschema#ResultError +func (e SchemaValidationError) Errors() []gojsonschema.ResultError { + return e +} + +// JSONPointer returns a dot (.) delimited "JSON path" to the context of the first error. +func (e SchemaValidationError) JSONPointer() string { + return []gojsonschema.ResultError(e)[0].Field() +} + +func (e SchemaValidationError) asMultiError() MultiError { + errs := make([]error, 0, len(e)) + for _, re := range e { + errs = append(errs, errors.New(re.String())) + } + return errs +} + +type SchemaError struct { + Value interface{} + reversePath []string //FIXME + Schema *Schema + SchemaField string + Reason string + Origin error //FIXME +} + +func (err *SchemaError) JSONPointer() []string { + return nil //FIXME +} + +func (err *SchemaError) Error() string { + // if err.Origin != nil { + // return err.Origin.Error() + // } + + buf := bytes.NewBuffer(make([]byte, 0, 256)) + // if len(err.reversePath) > 0 { + // buf.WriteString(`Error at "`) + // reversePath := err.reversePath + // for i := len(reversePath) - 1; i >= 0; i-- { + // buf.WriteByte('/') + // buf.WriteString(reversePath[i]) + // } + // buf.WriteString(`": `) + // } + reason := err.Reason + if reason == "" { + buf.WriteString(`Doesn't match schema "`) + buf.WriteString(err.SchemaField) + buf.WriteString(`"`) + } else { + buf.WriteString(reason) + } + { // if !SchemaErrorDetailsDisabled { + buf.WriteString("\nSchema:\n ") + encoder := json.NewEncoder(buf) + encoder.SetIndent(" ", " ") + if err := encoder.Encode(err.Schema); err != nil { + panic(err) + } + buf.WriteString("\nValue:\n ") + if err := encoder.Encode(err.Value); err != nil { + panic(err) + } + } + return buf.String() +} diff --git a/openapi3filter/unpack_errors_test.go b/openapi3filter/unpack_errors_test.go index 4242177f9..ddc480c5b 100644 --- a/openapi3filter/unpack_errors_test.go +++ b/openapi3filter/unpack_errors_test.go @@ -13,10 +13,14 @@ import ( ) func Example() { - doc, err := openapi3.NewLoader().LoadFromFile("./testdata/petstore.yaml") + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile("./testdata/petstore.yaml") if err != nil { panic(err) } + if err = doc.Validate(loader.Context); err != nil { + panic(err) + } router, err := gorillamux.NewRouter(doc) if err != nil {