From f6cad16373ac59c0def01e5c022097ab06020a9d Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Thu, 21 Sep 2023 13:59:38 +0200 Subject: [PATCH] Add nullable field tag to override nullability (#96) --- .github/workflows/bench.yml | 2 +- .github/workflows/golangci-lint.yml | 6 ++--- .github/workflows/gorelease.yml | 2 +- .github/workflows/test-unit.yml | 12 +++++----- .golangci.yml | 2 ++ Makefile | 2 +- README.md | 1 + go.mod | 4 ++-- go.sum | 8 +++---- helper.go | 34 +++++++++++++++++++++++++++++ reflect.go | 26 ++++++++++++++++++++-- reflect_test.go | 32 ++++++++++++++++++++++++++- 12 files changed, 110 insertions(+), 21 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index bd4fb36..51fbc24 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -21,7 +21,7 @@ env: GO111MODULE: "on" CACHE_BENCHMARK: "off" # Enables benchmark result reuse between runs, may skew latency results. RUN_BASE_BENCHMARK: "on" # Runs benchmark for PR base in case benchmark result is missing. - GO_VERSION: 1.20.x + GO_VERSION: 1.21.x jobs: bench: runs-on: ubuntu-latest diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index e651212..d2e4b61 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,13 +21,13 @@ jobs: steps: - uses: actions/setup-go@v3 with: - go-version: 1.20.x + go-version: 1.21.x - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v3.4.0 + uses: golangci/golangci-lint-action@v3.7.0 with: # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.51.1 + version: v1.54.1 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/gorelease.yml b/.github/workflows/gorelease.yml index 41767ba..97ebe87 100644 --- a/.github/workflows/gorelease.yml +++ b/.github/workflows/gorelease.yml @@ -9,7 +9,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: 1.20.x + GO_VERSION: 1.21.x jobs: gorelease: runs-on: ubuntu-latest diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 2817dcc..1f61fb8 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -15,13 +15,13 @@ concurrency: env: GO111MODULE: "on" RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. - COV_GO_VERSION: 1.20.x # Version of Go to collect coverage + COV_GO_VERSION: 1.21.x # Version of Go to collect coverage TARGET_DELTA_COV: 90 # Target coverage of changed lines, in percents jobs: test: strategy: matrix: - go-version: [ 1.13.x, 1.19.x, 1.20.x ] + go-version: [ 1.13.x, 1.20.x, 1.21.x ] runs-on: ubuntu-latest steps: - name: Install Go stable @@ -88,14 +88,14 @@ jobs: id: annotate if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' run: | - curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.3.6/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.4.0/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz && rm linux_amd64.tar.gz gocovdiff_hash=$(git hash-object ./gocovdiff) - [ "$gocovdiff_hash" == "8e507e0d671d4d6dfb3612309b72b163492f28eb" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) + [ "$gocovdiff_hash" == "f191b45548bb65ec2c7d88909679a57116ff1ba1" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) git fetch origin master ${{ github.event.pull_request.base.sha }} - REP=$(./gocovdiff -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) + REP=$(./gocovdiff -mod github.com/$GITHUB_REPOSITORY -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) echo "${REP}" cat gha-unit.txt - DIFF=$(test -e unit-base.txt && ./gocovdiff -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") + DIFF=$(test -e unit-base.txt && ./gocovdiff -mod github.com/$GITHUB_REPOSITORY -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") TOTAL=$(cat delta-cov-unit.txt) echo "rep<> $GITHUB_OUTPUT && echo "$REP" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT echo "diff<> $GITHUB_OUTPUT && echo "$DIFF" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT diff --git a/.golangci.yml b/.golangci.yml index 92c920d..ec2253b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -51,6 +51,8 @@ linters: - deadcode - testableexamples - dupword + - depguard + - tagalign issues: exclude: diff --git a/Makefile b/Makefile index 1b3abc4..76a4764 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -#GOLANGCI_LINT_VERSION := "v1.51.1" # Optional configuration to pinpoint golangci-lint version. +#GOLANGCI_LINT_VERSION := "v1.54.1" # Optional configuration to pinpoint golangci-lint version. # The head of Makefile determines location of dev-go to include standard targets. GO ?= go diff --git a/README.md b/README.md index ba5ce42..e779b8e 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ These tags can be used: * [`uniqueItems`](https://json-schema.org/draft-04/json-schema-validation.html#rfc.section.5.3.4), boolean * [`enum`](https://json-schema.org/draft-04/json-schema-validation.html#rfc.section.5.5.1), tag value must be a JSON or comma-separated list of strings * `required`, boolean, marks property as required +* `nullable`, boolean, overrides nullability of the property Unnamed fields can be used to configure parent schema: diff --git a/go.mod b/go.mod index 9c4a49e..e34390c 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module github.com/swaggest/jsonschema-go go 1.18 require ( - github.com/bool64/dev v0.2.29 + github.com/bool64/dev v0.2.31 github.com/stretchr/testify v1.8.2 github.com/swaggest/assertjson v1.9.0 - github.com/swaggest/refl v1.2.0 + github.com/swaggest/refl v1.2.1 github.com/yudai/gojsondiff v1.0.0 ) diff --git a/go.sum b/go.sum index abdf58c..ba2f1cc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/bool64/dev v0.2.29 h1:x+syGyh+0eWtOzQ1ItvLzOGIWyNWnyjXpHIcpF2HvL4= -github.com/bool64/dev v0.2.29/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.31 h1:OS57EqYaYe2M/2bw9uhDCIFiZZwywKFS/4qMLN6JUmQ= +github.com/bool64/dev v0.2.31/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -35,8 +35,8 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/refl v1.2.0 h1:Qqqhfwi7REXF6/4cwJmj3gQMzl0Q0cYquxTYdD0kvi0= -github.com/swaggest/refl v1.2.0/go.mod h1:CkC6g7h1PW33KprTuYRSw8UUOslRUt4lF3oe7tTIgNU= +github.com/swaggest/refl v1.2.1 h1:1meX9NaXjM5lmb4kk4RP3OZsXFRke9B1EHAP/pCEKO0= +github.com/swaggest/refl v1.2.1/go.mod h1:CkC6g7h1PW33KprTuYRSw8UUOslRUt4lF3oe7tTIgNU= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= diff --git a/helper.go b/helper.go index 421d604..44a62ef 100644 --- a/helper.go +++ b/helper.go @@ -107,6 +107,40 @@ func (i SimpleType) ToSchemaOrBool() SchemaOrBool { } } +// RemoveType removes simple type from Schema. +// +// If there is no type, no change is made. +func (s *Schema) RemoveType(t SimpleType) { + if s.Type == nil { + return + } + + if s.Type.SimpleTypes != nil { + if *s.Type.SimpleTypes == t { + s.Type = nil + } + + return + } + + if len(s.Type.SliceOfSimpleTypeValues) > 0 { + var tt []SimpleType + + for _, st := range s.Type.SliceOfSimpleTypeValues { + if st != t { + tt = append(tt, st) + } + } + + if len(tt) == 1 { + s.Type.SimpleTypes = &tt[0] + s.Type.SliceOfSimpleTypeValues = nil + } else { + s.Type.SliceOfSimpleTypeValues = tt + } + } +} + // AddType adds simple type to Schema. // // If type is already there it is ignored. diff --git a/reflect.go b/reflect.go index 6afc753..164b6e4 100644 --- a/reflect.go +++ b/reflect.go @@ -194,6 +194,8 @@ func checkSchemaSetup(params InterceptSchemaParams) (bool, error) { // - `uniqueItems`, https://json-schema.org/draft-04/json-schema-validation.html#rfc.section.5.3.4 // - `enum`, tag value must be a JSON or comma-separated list of strings, // https://json-schema.org/draft-04/json-schema-validation.html#rfc.section.5.5.1 +// - `required`, boolean, marks property as required +// - `nullable`, boolean, overrides nullability of a property // // Unnamed fields can be used to configure parent schema: // @@ -930,6 +932,8 @@ func (r *Reflector) walkProperties(v reflect.Value, parent *Schema, rc *ReflectC omitEmpty := strings.Contains(tag, ",omitempty") required := false + var nullable *bool + if propName == "" { propName = field.Name } @@ -938,6 +942,10 @@ func (r *Reflector) walkProperties(v reflect.Value, parent *Schema, rc *ReflectC return err } + if err := refl.ReadBoolPtrTag(field.Tag, "nullable", &nullable); err != nil { + return err + } + if required { parent.Required = append(parent.Required, propName) } @@ -973,7 +981,7 @@ func (r *Reflector) walkProperties(v reflect.Value, parent *Schema, rc *ReflectC return err } - checkNullability(&propertySchema, rc, ft, omitEmpty) + checkNullability(&propertySchema, rc, ft, omitEmpty, nullable) if !rc.SkipNonConstraints { err = checkInlineValue(&propertySchema, field, "default", propertySchema.WithDefault) @@ -1137,7 +1145,7 @@ func checkInlineValue(propertySchema *Schema, field reflect.StructField, tag str // - Array, slice accepts `null` as a value. // - Object without properties, it is a map, and it accepts `null` as a value. // - Pointer type. -func checkNullability(propertySchema *Schema, rc *ReflectContext, ft reflect.Type, omitEmpty bool) { +func checkNullability(propertySchema *Schema, rc *ReflectContext, ft reflect.Type, omitEmpty bool, nullable *bool) { in := InterceptNullabilityParams{ Context: rc, OrigSchema: *propertySchema, @@ -1152,6 +1160,20 @@ func checkNullability(propertySchema *Schema, rc *ReflectContext, ft reflect.Typ } }() + if nullable != nil { + if *nullable { + propertySchema.AddType(Null) + + in.NullAdded = true + } else if propertySchema.Ref == nil && propertySchema.HasType(Null) { + propertySchema.RemoveType(Null) + + in.NullAdded = false + } + + return + } + if omitEmpty { return } diff --git a/reflect_test.go b/reflect_test.go index 6895973..112eeb0 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -1361,7 +1361,7 @@ func TestReflector_Reflect_pointer_sharing(t *testing.T) { type tt struct{} -func (t *tt) UnmarshalText(text []byte) error { +func (t *tt) UnmarshalText(_ []byte) error { return nil } @@ -1649,3 +1649,33 @@ func TestReflector_Reflect_deeplyEmbeddedUnexported(t *testing.T) { "type":"object" }`, s) } + +func TestReflector_Reflect_nullable(t *testing.T) { + r := jsonschema.Reflector{} + + type My struct { + List1 []string `json:"l1"` + List2 []int `json:"l2"` + List3 []string `json:"l3" nullable:"false"` + S1 string `json:"s1" nullable:"true"` + S2 *string `json:"s2" nullable:"false"` + Map1 map[string]int `json:"m1"` + Map2 map[string]int `json:"m2" nullable:"false"` + } + + s, err := r.Reflect(My{}) + require.NoError(t, err) + + assertjson.EqMarshal(t, `{ + "properties":{ + "l1":{"items":{"type":"string"},"type":["array","null"]}, + "l2":{"items":{"type":"integer"},"type":["array","null"]}, + "l3":{"items":{"type":"string"},"type":"array"}, + "m1":{"additionalProperties":{"type":"integer"},"type":["object","null"]}, + "m2":{"additionalProperties":{"type":"integer"},"type":"object"}, + "s1":{"type":["string","null"]}, + "s2":{"type":"string"} + }, + "type":"object" + }`, s) +}