diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index e895222..4fddcb7 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/setup-go@v4 with: - go-version: "1.17" + go-version: "1.18" cache: false - name: Check out code diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 795dc21..0930332 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,19 +6,19 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go - uses: actions/setup-go@v1 - with: - go-version: '1.16.2' - id: go + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: "1.18" + id: go - - name: Check out code - uses: actions/checkout@v2 + - name: Check out code + uses: actions/checkout@v2 - - name: Install Dependencies - env: - GOPROXY: https://proxy.golang.org,direct - run: go mod download + - name: Install Dependencies + env: + GOPROXY: https://proxy.golang.org,direct + run: go mod download - - name: Test - run: go test -tags unit -race ./... + - name: Test + run: go test -tags unit -race ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ef0e14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +.idea/ diff --git a/.golangci.yml b/.golangci.yml index 905f122..3dac8a3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -43,6 +43,12 @@ linters-settings: gocritic: disabled-checks: - ifElseChain + gofmt: + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + - pattern: 'a[b:len(a)]' + replacement: 'a[b:]' issues: max-per-linter: 0 diff --git a/README.md b/README.md index 42ac355..65ed8ae 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ This repository is a fork of the original [jsonschema](https://github.com/alecth ## Versions -This project is still under v0 scheme, as per Go convention, breaking changes are likely. Please pin go modules to branches, and reach out if you think something can be improved. +This project is still under v0 scheme, as per Go convention, breaking changes are likely. Please pin go modules to version tags or branches, and reach out if you think something can be improved. + +Go version >= 1.18 is required as generics are now being used. ## Example diff --git a/comment_extractor.go b/comment_extractor.go index 0088b41..e157837 100644 --- a/comment_extractor.go +++ b/comment_extractor.go @@ -69,6 +69,9 @@ func ExtractGoComments(base, path string, commentMap map[string]string) error { } case *ast.Field: txt := x.Doc.Text() + if txt == "" { + txt = x.Comment.Text() + } if typ != "" && txt != "" { for _, n := range x.Names { if ast.IsExported(n.String()) { diff --git a/examples/nested/nested.go b/examples/nested/nested.go index 886c5cf..bfdceb3 100644 --- a/examples/nested/nested.go +++ b/examples/nested/nested.go @@ -16,6 +16,8 @@ type ( // Plant represents the plants the user might have and serves as a test // of structs inside a `type` set. Plant struct { - Variant string `json:"variant" jsonschema:"title=Variant"` // This comment will be ignored + Variant string `json:"variant" jsonschema:"title=Variant"` // This comment will be used + // Multicellular is true if the plant is multicellular + Multicellular bool `json:"multicellular,omitempty" jsonschema:"title=Multicellular"` // This comment will be ignored } ) diff --git a/examples/user.go b/examples/user.go index 710f12e..4c5b484 100644 --- a/examples/user.go +++ b/examples/user.go @@ -10,9 +10,9 @@ type User struct { // Unique sequential identifier. ID int `json:"id" jsonschema:"required"` // This comment will be ignored - Name string `json:"name" jsonschema:"required,minLength=1,maxLength=20,pattern=.*,description=this is a property,title=the name,example=joe,example=lucy,default=alex"` - Friends []int `json:"friends,omitempty" jsonschema_description:"list of IDs, omitted when empty"` - Tags map[string]interface{} `json:"tags,omitempty"` + Name string `json:"name" jsonschema:"required,minLength=1,maxLength=20,pattern=.*,description=this is a property,title=the name,example=joe,example=lucy,default=alex"` + Friends []int `json:"friends,omitempty" jsonschema_description:"list of IDs, omitted when empty"` + Tags map[string]any `json:"tags,omitempty"` // An array of pets the user cares for. Pets nested.Pets `json:"pets"` diff --git a/examples_test.go b/examples_test.go index 82cae05..5363458 100644 --- a/examples_test.go +++ b/examples_test.go @@ -9,14 +9,14 @@ import ( ) type SampleUser struct { - ID int `json:"id"` - Name string `json:"name" jsonschema:"title=the name,description=The name of a friend,example=joe,example=lucy,default=alex"` - Friends []int `json:"friends,omitempty" jsonschema_description:"The list of IDs, omitted when empty"` - Tags map[string]interface{} `json:"tags,omitempty" jsonschema_extras:"a=b,foo=bar,foo=bar1"` - BirthDate time.Time `json:"birth_date,omitempty" jsonschema:"oneof_required=date"` - YearOfBirth string `json:"year_of_birth,omitempty" jsonschema:"oneof_required=year"` - Metadata interface{} `json:"metadata,omitempty" jsonschema:"oneof_type=string;array"` - FavColor string `json:"fav_color,omitempty" jsonschema:"enum=red,enum=green,enum=blue"` + ID int `json:"id"` + Name string `json:"name" jsonschema:"title=the name,description=The name of a friend,example=joe,example=lucy,default=alex"` + Friends []int `json:"friends,omitempty" jsonschema_description:"The list of IDs, omitted when empty"` + Tags map[string]any `json:"tags,omitempty" jsonschema_extras:"a=b,foo=bar,foo=bar1"` + BirthDate time.Time `json:"birth_date,omitempty" jsonschema:"oneof_required=date"` + YearOfBirth string `json:"year_of_birth,omitempty" jsonschema:"oneof_required=year"` + Metadata any `json:"metadata,omitempty" jsonschema:"oneof_type=string;array"` + FavColor string `json:"fav_color,omitempty" jsonschema:"enum=red,enum=green,enum=blue"` } func ExampleReflect() { diff --git a/fixtures/allow_additional_props.json b/fixtures/allow_additional_props.json index bb4aa2c..7e89cf4 100644 --- a/fixtures/allow_additional_props.json +++ b/fixtures/allow_additional_props.json @@ -118,9 +118,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/array_handling.json b/fixtures/array_handling.json new file mode 100644 index 0000000..b90ceeb --- /dev/null +++ b/fixtures/array_handling.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/array-handler", + "$ref": "#/$defs/ArrayHandler", + "$defs": { + "ArrayHandler": { + "properties": { + "min_len": { + "default": [ + "qwerty" + ], + "items": { + "type": "string", + "minLength": 2 + }, + "type": "array" + }, + "min_val": { + "items": { + "type": "number", + "minimum": 2.5 + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "min_len", + "min_val" + ] + } + } +} \ No newline at end of file diff --git a/fixtures/custom_base_schema_id.json b/fixtures/custom_base_schema_id.json index 77f8f84..479abc6 100644 --- a/fixtures/custom_base_schema_id.json +++ b/fixtures/custom_base_schema_id.json @@ -107,9 +107,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/defaults_expanded_toplevel.json b/fixtures/defaults_expanded_toplevel.json index cc51d10..09d592c 100644 --- a/fixtures/defaults_expanded_toplevel.json +++ b/fixtures/defaults_expanded_toplevel.json @@ -118,9 +118,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/go_comments.json b/fixtures/go_comments.json index 2e675cc..bfe5e61 100644 --- a/fixtures/go_comments.json +++ b/fixtures/go_comments.json @@ -36,7 +36,13 @@ "properties": { "variant": { "type": "string", - "title": "Variant" + "title": "Variant", + "description": "This comment will be used" + }, + "multicellular": { + "type": "boolean", + "title": "Multicellular", + "description": "Multicellular is true if the plant is multicellular" } }, "additionalProperties": false, diff --git a/fixtures/ignore_type.json b/fixtures/ignore_type.json index 1b4da5c..6e7f997 100644 --- a/fixtures/ignore_type.json +++ b/fixtures/ignore_type.json @@ -112,9 +112,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/no_reference.json b/fixtures/no_reference.json index eab2dce..6efab45 100644 --- a/fixtures/no_reference.json +++ b/fixtures/no_reference.json @@ -107,9 +107,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/no_reference_anchor.json b/fixtures/no_reference_anchor.json index 03963b2..d544d93 100644 --- a/fixtures/no_reference_anchor.json +++ b/fixtures/no_reference_anchor.json @@ -109,9 +109,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/required_from_jsontags.json b/fixtures/required_from_jsontags.json index a3494af..7173eaa 100644 --- a/fixtures/required_from_jsontags.json +++ b/fixtures/required_from_jsontags.json @@ -119,9 +119,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/test_user.json b/fixtures/test_user.json index bd32c02..97d3b3b 100644 --- a/fixtures/test_user.json +++ b/fixtures/test_user.json @@ -119,9 +119,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/test_user_assign_anchor.json b/fixtures/test_user_assign_anchor.json index 406c881..e5691bc 100644 --- a/fixtures/test_user_assign_anchor.json +++ b/fixtures/test_user_assign_anchor.json @@ -121,9 +121,9 @@ "age": { "type": "integer", "maximum": 120, - "exclusiveMaximum": true, + "exclusiveMaximum": 121, "minimum": 18, - "exclusiveMinimum": true + "exclusiveMinimum": 17 }, "email": { "type": "string", diff --git a/fixtures/unsigned_int_handling.json b/fixtures/unsigned_int_handling.json new file mode 100644 index 0000000..f5d407f --- /dev/null +++ b/fixtures/unsigned_int_handling.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/unsigned-int-handler", + "$ref": "#/$defs/UnsignedIntHandler", + "$defs": { + "UnsignedIntHandler": { + "properties": { + "min_len": { + "items": { + "type": "string", + "minLength": 0 + }, + "type": "array" + }, + "max_len": { + "items": { + "type": "string", + "maxLength": 0 + }, + "type": "array" + }, + "min_items": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 0 + }, + "max_items": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 0 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "min_len", + "max_len", + "min_items", + "max_items" + ] + } + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index aefedd7..49c0e35 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,17 @@ module github.com/invopop/jsonschema -go 1.16 +go 1.18 require ( - github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 - github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709 + github.com/stretchr/testify v1.8.1 + github.com/wk8/go-ordered-map/v2 v2.1.8 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5e8841f..025db2e 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,26 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= -github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709 h1:Ko2LQMrRU+Oy/+EDBwX7eZ2jp3C47eDBB8EIhKTun+I= -github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/reflect.go b/reflect.go index d7dce99..ad9b56d 100644 --- a/reflect.go +++ b/reflect.go @@ -16,7 +16,7 @@ import ( "strings" "time" - "github.com/iancoleman/orderedmap" + orderedmap "github.com/wk8/go-ordered-map/v2" ) // Version is the JSON Schema version. @@ -48,29 +48,29 @@ type Schema struct { Items *Schema `json:"items,omitempty"` // section 10.3.1.2 (replaces additionalItems) Contains *Schema `json:"contains,omitempty"` // section 10.3.1.3 // RFC draft-bhutton-json-schema-00 section 10.3.2 (sub-schemas) - Properties *orderedmap.OrderedMap `json:"properties,omitempty"` // section 10.3.2.1 - PatternProperties map[string]*Schema `json:"patternProperties,omitempty"` // section 10.3.2.2 - AdditionalProperties *Schema `json:"additionalProperties,omitempty"` // section 10.3.2.3 - PropertyNames *Schema `json:"propertyNames,omitempty"` // section 10.3.2.4 + Properties *orderedmap.OrderedMap[string, *Schema] `json:"properties,omitempty"` // section 10.3.2.1 + PatternProperties map[string]*Schema `json:"patternProperties,omitempty"` // section 10.3.2.2 + AdditionalProperties *Schema `json:"additionalProperties,omitempty"` // section 10.3.2.3 + PropertyNames *Schema `json:"propertyNames,omitempty"` // section 10.3.2.4 // RFC draft-bhutton-json-schema-validation-00, section 6 Type string `json:"type,omitempty"` // section 6.1.1 - Enum []interface{} `json:"enum,omitempty"` // section 6.1.2 - Const interface{} `json:"const,omitempty"` // section 6.1.3 - MultipleOf int `json:"multipleOf,omitempty"` // section 6.2.1 - Maximum int `json:"maximum,omitempty"` // section 6.2.2 - ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty"` // section 6.2.3 - Minimum int `json:"minimum,omitempty"` // section 6.2.4 - ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty"` // section 6.2.5 - MaxLength int `json:"maxLength,omitempty"` // section 6.3.1 - MinLength int `json:"minLength,omitempty"` // section 6.3.2 + Enum []any `json:"enum,omitempty"` // section 6.1.2 + Const any `json:"const,omitempty"` // section 6.1.3 + MultipleOf json.Number `json:"multipleOf,omitempty"` // section 6.2.1 + Maximum json.Number `json:"maximum,omitempty"` // section 6.2.2 + ExclusiveMaximum json.Number `json:"exclusiveMaximum,omitempty"` // section 6.2.3 + Minimum json.Number `json:"minimum,omitempty"` // section 6.2.4 + ExclusiveMinimum json.Number `json:"exclusiveMinimum,omitempty"` // section 6.2.5 + MaxLength *uint64 `json:"maxLength,omitempty"` // section 6.3.1 + MinLength *uint64 `json:"minLength,omitempty"` // section 6.3.2 Pattern string `json:"pattern,omitempty"` // section 6.3.3 - MaxItems int `json:"maxItems,omitempty"` // section 6.4.1 - MinItems int `json:"minItems,omitempty"` // section 6.4.2 + MaxItems *uint64 `json:"maxItems,omitempty"` // section 6.4.1 + MinItems *uint64 `json:"minItems,omitempty"` // section 6.4.2 UniqueItems bool `json:"uniqueItems,omitempty"` // section 6.4.3 - MaxContains uint `json:"maxContains,omitempty"` // section 6.4.4 - MinContains uint `json:"minContains,omitempty"` // section 6.4.5 - MaxProperties int `json:"maxProperties,omitempty"` // section 6.5.1 - MinProperties int `json:"minProperties,omitempty"` // section 6.5.2 + MaxContains *uint64 `json:"maxContains,omitempty"` // section 6.4.4 + MinContains *uint64 `json:"minContains,omitempty"` // section 6.4.5 + MaxProperties *uint64 `json:"maxProperties,omitempty"` // section 6.5.1 + MinProperties *uint64 `json:"minProperties,omitempty"` // section 6.5.2 Required []string `json:"required,omitempty"` // section 6.5.3 DependentRequired map[string][]string `json:"dependentRequired,omitempty"` // section 6.5.4 // RFC draft-bhutton-json-schema-validation-00, section 7 @@ -80,15 +80,15 @@ type Schema struct { ContentMediaType string `json:"contentMediaType,omitempty"` // section 8.4 ContentSchema *Schema `json:"contentSchema,omitempty"` // section 8.5 // RFC draft-bhutton-json-schema-validation-00, section 9 - Title string `json:"title,omitempty"` // section 9.1 - Description string `json:"description,omitempty"` // section 9.1 - Default interface{} `json:"default,omitempty"` // section 9.2 - Deprecated bool `json:"deprecated,omitempty"` // section 9.3 - ReadOnly bool `json:"readOnly,omitempty"` // section 9.4 - WriteOnly bool `json:"writeOnly,omitempty"` // section 9.4 - Examples []interface{} `json:"examples,omitempty"` // section 9.5 + Title string `json:"title,omitempty"` // section 9.1 + Description string `json:"description,omitempty"` // section 9.1 + Default any `json:"default,omitempty"` // section 9.2 + Deprecated bool `json:"deprecated,omitempty"` // section 9.3 + ReadOnly bool `json:"readOnly,omitempty"` // section 9.4 + WriteOnly bool `json:"writeOnly,omitempty"` // section 9.4 + Examples []any `json:"examples,omitempty"` // section 9.5 - Extras map[string]interface{} `json:"-"` + Extras map[string]any `json:"-"` // Special boolean representation of the Schema - section 4.3.2 boolean *bool @@ -127,7 +127,7 @@ type customGetFieldDocString func(fieldName string) string var customStructGetFieldDocString = reflect.TypeOf((*customSchemaGetFieldDocString)(nil)).Elem() // Reflect reflects to Schema from a value using the default Reflector -func Reflect(v interface{}) *Schema { +func Reflect(v any) *Schema { return ReflectFromType(reflect.TypeOf(v)) } @@ -188,7 +188,7 @@ type Reflector struct { // IgnoredTypes defines a slice of types that should be ignored in the schema, // switching to just allowing additional properties instead. - IgnoredTypes []interface{} + IgnoredTypes []any // Lookup allows a function to be defined that will provide a custom mapping of // types to Schema IDs. This allows existing schema documents to be referenced @@ -229,7 +229,7 @@ type Reflector struct { } // Reflect reflects to Schema from a value. -func (r *Reflector) Reflect(v interface{}) *Schema { +func (r *Reflector) Reflect(v any) *Schema { return r.ReflectFromType(reflect.TypeOf(v)) } @@ -459,8 +459,9 @@ func (r *Reflector) reflectSliceOrArray(definitions Definitions, t reflect.Type, } if t.Kind() == reflect.Array { - st.MinItems = t.Len() - st.MaxItems = st.MinItems + l := uint64(t.Len()) + st.MinItems = &l + st.MaxItems = &l } if t.Kind() == reflect.Slice && t.Elem() == byteSliceType.Elem() { st.Type = "string" @@ -509,7 +510,7 @@ func (r *Reflector) reflectStruct(definitions Definitions, t reflect.Type, s *Sc r.addDefinition(definitions, t, s) s.Type = "object" - s.Properties = orderedmap.New() + s.Properties = NewProperties() s.Description = r.lookupComment(t, "") if r.AssignAnchor { s.Anchor = t.Name() @@ -658,7 +659,7 @@ func (t *Schema) structKeywordsFromTags(f reflect.StructField, parent *Schema, p t.Description = f.Tag.Get("jsonschema_description") tags := splitOnUnescapedCommas(f.Tag.Get("jsonschema")) - t.genericKeywords(tags, parent, propertyName) + tags = t.genericKeywords(tags, parent, propertyName) switch t.Type { case "string": @@ -676,8 +677,9 @@ func (t *Schema) structKeywordsFromTags(f reflect.StructField, parent *Schema, p t.extraKeywords(extras) } -// read struct tags for generic keyworks -func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName string) { //nolint:gocyclo +// read struct tags for generic keywords +func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName string) []string { //nolint:gocyclo + unprocessed := make([]string, 0, len(tags)) for _, tag := range tags { nameValue := strings.Split(tag, "=") if len(nameValue) == 2 { @@ -773,23 +775,15 @@ func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName str Type: ty, }) } - case "enum": - switch t.Type { - case "string": - t.Enum = append(t.Enum, val) - case "integer": - i, _ := strconv.Atoi(val) - t.Enum = append(t.Enum, i) - case "number": - f, _ := strconv.ParseFloat(val, 64) - t.Enum = append(t.Enum, f) - } + default: + unprocessed = append(unprocessed, tag) } } } + return unprocessed } -// read struct tags for boolean type keyworks +// read struct tags for boolean type keywords func (t *Schema) booleanKeywords(tags []string) { for _, tag := range tags { nameValue := strings.Split(tag, "=") @@ -807,7 +801,7 @@ func (t *Schema) booleanKeywords(tags []string) { } } -// read struct tags for string type keyworks +// read struct tags for string type keywords func (t *Schema) stringKeywords(tags []string) { for _, tag := range tags { nameValue := strings.SplitN(tag, "=", 2) @@ -815,11 +809,9 @@ func (t *Schema) stringKeywords(tags []string) { name, val := nameValue[0], nameValue[1] switch name { case "minLength": - i, _ := strconv.Atoi(val) - t.MinLength = i + t.MinLength = parseUint(val) case "maxLength": - i, _ := strconv.Atoi(val) - t.MaxLength = i + t.MaxLength = parseUint(val) case "pattern": t.Pattern = val case "format": @@ -837,12 +829,14 @@ func (t *Schema) stringKeywords(tags []string) { t.Default = val case "example": t.Examples = append(t.Examples, val) + case "enum": + t.Enum = append(t.Enum, val) } } } } -// read struct tags for numerical type keyworks +// read struct tags for numerical type keywords func (t *Schema) numericalKeywords(tags []string) { for _, tag := range tags { nameValue := strings.Split(tag, "=") @@ -850,33 +844,33 @@ func (t *Schema) numericalKeywords(tags []string) { name, val := nameValue[0], nameValue[1] switch name { case "multipleOf": - i, _ := strconv.Atoi(val) - t.MultipleOf = i + t.MultipleOf, _ = toJSONNumber(val) case "minimum": - i, _ := strconv.Atoi(val) - t.Minimum = i + t.Minimum, _ = toJSONNumber(val) case "maximum": - i, _ := strconv.Atoi(val) - t.Maximum = i + t.Maximum, _ = toJSONNumber(val) case "exclusiveMaximum": - b, _ := strconv.ParseBool(val) - t.ExclusiveMaximum = b + t.ExclusiveMaximum, _ = toJSONNumber(val) case "exclusiveMinimum": - b, _ := strconv.ParseBool(val) - t.ExclusiveMinimum = b + t.ExclusiveMinimum, _ = toJSONNumber(val) case "default": - n, _ := strconv.ParseFloat(val, 64) - t.Default = n + if num, ok := toJSONNumber(val); ok { + t.Default = num + } case "example": - if i, err := strconv.Atoi(val); err == nil { - t.Examples = append(t.Examples, i) + if num, ok := toJSONNumber(val); ok { + t.Examples = append(t.Examples, num) + } + case "enum": + if num, ok := toJSONNumber(val); ok { + t.Enum = append(t.Enum, num) } } } } } -// read struct tags for object type keyworks +// read struct tags for object type keywords // func (t *Type) objectKeywords(tags []string) { // for _, tag := range tags{ // nameValue := strings.Split(tag, "=") @@ -892,45 +886,54 @@ func (t *Schema) numericalKeywords(tags []string) { // } // } -// read struct tags for array type keyworks +// read struct tags for array type keywords func (t *Schema) arrayKeywords(tags []string) { - var defaultValues []interface{} + var defaultValues []any + + unprocessed := make([]string, 0, len(tags)) for _, tag := range tags { nameValue := strings.Split(tag, "=") if len(nameValue) == 2 { name, val := nameValue[0], nameValue[1] switch name { case "minItems": - i, _ := strconv.Atoi(val) - t.MinItems = i + t.MinItems = parseUint(val) case "maxItems": - i, _ := strconv.Atoi(val) - t.MaxItems = i + t.MaxItems = parseUint(val) case "uniqueItems": t.UniqueItems = true case "default": defaultValues = append(defaultValues, val) - case "enum": - switch t.Items.Type { - case "string": - t.Items.Enum = append(t.Items.Enum, val) - case "integer": - i, _ := strconv.Atoi(val) - t.Items.Enum = append(t.Items.Enum, i) - case "number": - f, _ := strconv.ParseFloat(val, 64) - t.Items.Enum = append(t.Items.Enum, f) - } case "format": t.Items.Format = val case "pattern": t.Items.Pattern = val + default: + unprocessed = append(unprocessed, tag) // left for further processing by underlying type } } } if len(defaultValues) > 0 { t.Default = defaultValues } + + if len(unprocessed) == 0 { + // we don't have anything else to process + return + } + + switch t.Items.Type { + case "string": + t.Items.stringKeywords(unprocessed) + case "number": + t.Items.numericalKeywords(unprocessed) + case "integer": + t.Items.numericalKeywords(unprocessed) + case "array": + // explicitly don't support traversal for the [][]..., as it's unclear where the array tags belong + case "boolean": + t.Items.booleanKeywords(unprocessed) + } } func (t *Schema) extraKeywords(tags []string) { @@ -944,7 +947,7 @@ func (t *Schema) extraKeywords(tags []string) { func (t *Schema) setExtra(key, val string) { if t.Extras == nil { - t.Extras = map[string]interface{}{} + t.Extras = map[string]any{} } if existingVal, ok := t.Extras[key]; ok { switch existingVal := existingVal.(type) { @@ -962,7 +965,7 @@ func (t *Schema) setExtra(key, val string) { case "minimum": t.Extras[key], _ = strconv.Atoi(val) default: - var x interface{} + var x any if val == "true" { x = true } else if val == "false" { @@ -1020,6 +1023,27 @@ func ignoredByJSONSchemaTags(tags []string) bool { return tags[0] == "-" } +// toJSONNumber converts string to *json.Number. +// It'll aso return whether the number is valid. +func toJSONNumber(s string) (json.Number, bool) { + num := json.Number(s) + if _, err := num.Int64(); err == nil { + return num, true + } + if _, err := num.Float64(); err == nil { + return num, true + } + return json.Number(""), false +} + +func parseUint(num string) *uint64 { + val, err := strconv.ParseUint(num, 10, 64) + if err != nil { + return nil + } + return &val +} + func (r *Reflector) fieldNameTag() string { if r.FieldNameTag != "" { return r.FieldNameTag diff --git a/reflect_test.go b/reflect_test.go index 35345d7..6147c74 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -13,8 +13,6 @@ import ( "testing" "time" - "github.com/iancoleman/orderedmap" - "github.com/invopop/jsonschema/examples" "github.com/stretchr/testify/assert" @@ -43,7 +41,7 @@ type SomeBaseType struct { someUnexportedUntaggedBaseProperty bool //nolint:unused } -type MapType map[string]interface{} +type MapType map[string]any type ArrayType []string @@ -66,12 +64,12 @@ type TestUser struct { nonExported MapType - ID int `json:"id" jsonschema:"required"` - Name string `json:"name" jsonschema:"required,minLength=1,maxLength=20,pattern=.*,description=this is a property,title=the name,example=joe,example=lucy,default=alex,readOnly=true"` - Password string `json:"password" jsonschema:"writeOnly=true"` - Friends []int `json:"friends,omitempty" jsonschema_description:"list of IDs, omitted when empty"` - Tags map[string]string `json:"tags,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` + ID int `json:"id" jsonschema:"required,minimum=bad,maximum=bad,exclusiveMinimum=bad,exclusiveMaximum=bad,default=bad"` + Name string `json:"name" jsonschema:"required,minLength=1,maxLength=20,pattern=.*,description=this is a property,title=the name,example=joe,example=lucy,default=alex,readOnly=true"` + Password string `json:"password" jsonschema:"writeOnly=true"` + Friends []int `json:"friends,omitempty" jsonschema_description:"list of IDs, omitted when empty"` + Tags map[string]string `json:"tags,omitempty"` + Options map[string]any `json:"options,omitempty"` TestFlag bool TestFlagFalse bool `json:",omitempty" jsonschema:"default=false"` @@ -90,7 +88,7 @@ type TestUser struct { // Tests for jsonpb enum support Feeling ProtoEnum `json:"feeling,omitempty"` - Age int `json:"age" jsonschema:"minimum=18,maximum=120,exclusiveMaximum=true,exclusiveMinimum=true"` + Age int `json:"age" jsonschema:"minimum=18,maximum=120,exclusiveMaximum=121,exclusiveMinimum=17"` Email string `json:"email" jsonschema:"format=email"` UUID string `json:"uuid" jsonschema:"format=uuid"` @@ -109,7 +107,7 @@ type TestUser struct { Offsets []float64 `json:"offsets,omitempty" jsonschema:"enum=1.570796,enum=3.141592,enum=6.283185"` // Test for raw JSON - Anything interface{} `json:"anything,omitempty"` + Anything any `json:"anything,omitempty"` Raw json.RawMessage `json:"raw"` } @@ -133,34 +131,34 @@ func (CustomTimeWithInterface) JSONSchema() *Schema { } type RootOneOf struct { - Field1 string `json:"field1" jsonschema:"oneof_required=group1"` - Field2 string `json:"field2" jsonschema:"oneof_required=group2"` - Field3 interface{} `json:"field3" jsonschema:"oneof_type=string;array"` - Field4 string `json:"field4" jsonschema:"oneof_required=group1"` - Field5 ChildOneOf `json:"child"` - Field6 interface{} `json:"field6" jsonschema:"oneof_ref=Outer;OuterNamed;OuterPtr"` + Field1 string `json:"field1" jsonschema:"oneof_required=group1"` + Field2 string `json:"field2" jsonschema:"oneof_required=group2"` + Field3 any `json:"field3" jsonschema:"oneof_type=string;array"` + Field4 string `json:"field4" jsonschema:"oneof_required=group1"` + Field5 ChildOneOf `json:"child"` + Field6 any `json:"field6" jsonschema:"oneof_ref=Outer;OuterNamed;OuterPtr"` } type ChildOneOf struct { - Child1 string `json:"child1" jsonschema:"oneof_required=group1"` - Child2 string `json:"child2" jsonschema:"oneof_required=group2"` - Child3 interface{} `json:"child3" jsonschema:"oneof_required=group2,oneof_type=string;array"` - Child4 string `json:"child4" jsonschema:"oneof_required=group1"` + Child1 string `json:"child1" jsonschema:"oneof_required=group1"` + Child2 string `json:"child2" jsonschema:"oneof_required=group2"` + Child3 any `json:"child3" jsonschema:"oneof_required=group2,oneof_type=string;array"` + Child4 string `json:"child4" jsonschema:"oneof_required=group1"` } type RootAnyOf struct { - Field1 string `json:"field1" jsonschema:"anyof_required=group1"` - Field2 string `json:"field2" jsonschema:"anyof_required=group2"` - Field3 interface{} `json:"field3" jsonschema:"anyof_type=string;array"` - Field4 string `json:"field4" jsonschema:"anyof_required=group1"` - Field5 ChildAnyOf `json:"child"` + Field1 string `json:"field1" jsonschema:"anyof_required=group1"` + Field2 string `json:"field2" jsonschema:"anyof_required=group2"` + Field3 any `json:"field3" jsonschema:"anyof_type=string;array"` + Field4 string `json:"field4" jsonschema:"anyof_required=group1"` + Field5 ChildAnyOf `json:"child"` } type ChildAnyOf struct { - Child1 string `json:"child1" jsonschema:"anyof_required=group1"` - Child2 string `json:"child2" jsonschema:"anyof_required=group2"` - Child3 interface{} `json:"child3" jsonschema:"anyof_required=group2,oneof_type=string;array"` - Child4 string `json:"child4" jsonschema:"anyof_required=group1"` + Child1 string `json:"child1" jsonschema:"anyof_required=group1"` + Child2 string `json:"child2" jsonschema:"anyof_required=group2"` + Child3 any `json:"child3" jsonschema:"anyof_required=group2,oneof_type=string;array"` + Child4 string `json:"child4" jsonschema:"anyof_required=group1"` } type Text string @@ -268,7 +266,7 @@ func (CustomSliceType) JSONSchema() *Schema { type CustomMapType map[string]string func (CustomMapType) JSONSchema() *Schema { - properties := orderedmap.New() + properties := NewProperties() properties.Set("key", &Schema{ Type: "string", }) @@ -326,7 +324,7 @@ func (SchemaExtendTest) JSONSchemaExtend(base *Schema) { base.Properties.Delete("FirstName") base.Properties.Delete("age") val, _ := base.Properties.Get("LastName") - (val).(*Schema).Description = "some extra words" + val.Description = "some extra words" base.Required = []string{"LastName"} } @@ -366,7 +364,7 @@ func TestReflectFromType(t *testing.T) { func TestSchemaGeneration(t *testing.T) { tests := []struct { - typ interface{} + typ any reflector *Reflector fixture string }{ @@ -376,7 +374,7 @@ func TestSchemaGeneration(t *testing.T) { {&TestUser{}, &Reflector{AllowAdditionalProperties: true}, "fixtures/allow_additional_props.json"}, {&TestUser{}, &Reflector{RequiredFromJSONSchemaTags: true}, "fixtures/required_from_jsontags.json"}, {&TestUser{}, &Reflector{ExpandedStruct: true}, "fixtures/defaults_expanded_toplevel.json"}, - {&TestUser{}, &Reflector{IgnoredTypes: []interface{}{GrandfatherType{}}}, "fixtures/ignore_type.json"}, + {&TestUser{}, &Reflector{IgnoredTypes: []any{GrandfatherType{}}}, "fixtures/ignore_type.json"}, {&TestUser{}, &Reflector{DoNotReference: true}, "fixtures/no_reference.json"}, {&TestUser{}, &Reflector{DoNotReference: true, AssignAnchor: true}, "fixtures/no_reference_anchor.json"}, {&RootOneOf{}, &Reflector{RequiredFromJSONSchemaTags: true}, "fixtures/oneof.json"}, @@ -497,7 +495,7 @@ func TestBaselineUnmarshal(t *testing.T) { compareSchemaOutput(t, "fixtures/test_user.json", r, &TestUser{}) } -func compareSchemaOutput(t *testing.T, f string, r *Reflector, obj interface{}) { +func compareSchemaOutput(t *testing.T, f string, r *Reflector, obj any) { t.Helper() expectedJSON, err := os.ReadFile(f) require.NoError(t, err) @@ -552,10 +550,9 @@ func TestArrayExtraTags(t *testing.T) { require.NotNil(t, d) props := d.Properties require.NotNil(t, props) - i, found := props.Get("TestURIs") + p, found := props.Get("TestURIs") require.True(t, found) - p := i.(*Schema) pt := p.Items.Format require.Equal(t, pt, "uri") pt = p.Items.Pattern @@ -576,10 +573,10 @@ func TestFieldNameTag(t *testing.T) { func TestFieldOneOfRef(t *testing.T) { type Server struct { - IPAddress interface{} `json:"ip_address,omitempty" jsonschema:"oneof_ref=#/$defs/ipv4;#/$defs/ipv6"` - IPAddresses []interface{} `json:"ip_addresses,omitempty" jsonschema:"oneof_ref=#/$defs/ipv4;#/$defs/ipv6"` - IPAddressAny interface{} `json:"ip_address_any,omitempty" jsonschema:"anyof_ref=#/$defs/ipv4;#/$defs/ipv6"` - IPAddressesAny []interface{} `json:"ip_addresses_any,omitempty" jsonschema:"anyof_ref=#/$defs/ipv4;#/$defs/ipv6"` + IPAddress any `json:"ip_address,omitempty" jsonschema:"oneof_ref=#/$defs/ipv4;#/$defs/ipv6"` + IPAddresses []any `json:"ip_addresses,omitempty" jsonschema:"oneof_ref=#/$defs/ipv4;#/$defs/ipv6"` + IPAddressAny any `json:"ip_address_any,omitempty" jsonschema:"anyof_ref=#/$defs/ipv4;#/$defs/ipv6"` + IPAddressesAny []any `json:"ip_addresses_any,omitempty" jsonschema:"anyof_ref=#/$defs/ipv4;#/$defs/ipv6"` } r := &Reflector{} @@ -597,3 +594,31 @@ func TestNumberHandling(t *testing.T) { fixtureContains(t, "fixtures/number_handling.json", `"default": 12`) fixtureContains(t, "fixtures/number_handling.json", `"default": 12.5`) } + +func TestArrayHandling(t *testing.T) { + type ArrayHandler struct { + MinLen []string `json:"min_len" jsonschema:"minLength=2,default=qwerty"` + MinVal []float64 `json:"min_val" jsonschema:"minimum=2.5"` + } + + r := &Reflector{} + compareSchemaOutput(t, "fixtures/array_handling.json", r, &ArrayHandler{}) + fixtureContains(t, "fixtures/array_handling.json", `"minLength": 2`) + fixtureContains(t, "fixtures/array_handling.json", `"minimum": 2.5`) +} + +func TestUnsignedIntHandling(t *testing.T) { + type UnsignedIntHandler struct { + MinLen []string `json:"min_len" jsonschema:"minLength=0"` + MaxLen []string `json:"max_len" jsonschema:"maxLength=0"` + MinItems []string `json:"min_items" jsonschema:"minItems=0"` + MaxItems []string `json:"max_items" jsonschema:"maxItems=0"` + } + + r := &Reflector{} + compareSchemaOutput(t, "fixtures/unsigned_int_handling.json", r, &UnsignedIntHandler{}) + fixtureContains(t, "fixtures/unsigned_int_handling.json", `"minLength": 0`) + fixtureContains(t, "fixtures/unsigned_int_handling.json", `"maxLength": 0`) + fixtureContains(t, "fixtures/unsigned_int_handling.json", `"minItems": 0`) + fixtureContains(t, "fixtures/unsigned_int_handling.json", `"maxItems": 0`) +} diff --git a/utils.go b/utils.go index 9813b11..ed8edf7 100644 --- a/utils.go +++ b/utils.go @@ -3,6 +3,8 @@ package jsonschema import ( "regexp" "strings" + + orderedmap "github.com/wk8/go-ordered-map/v2" ) var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") @@ -16,3 +18,9 @@ func ToSnakeCase(str string) string { snake = matchAllCap.ReplaceAllString(snake, "${1}-${2}") return strings.ToLower(snake) } + +// NewProperties is a helper method to instantiate a new properties ordered +// map. +func NewProperties() *orderedmap.OrderedMap[string, *Schema] { + return orderedmap.New[string, *Schema]() +}