diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index a4de3ed..b5220c2 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -12,15 +12,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/setup-go@v4 - with: - go-version: "1.18" - cache: false - - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" - name: Lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v6 with: - version: v1.55 + version: v1.62 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0930332..e2a175a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,19 +1,18 @@ name: Test Go -on: [push, pull_request] +on: [push] jobs: - lint-test-build: - name: Lint, Test + test: + name: Test runs-on: ubuntu-latest steps: + - name: Check out code + uses: actions/checkout@v3 + - name: Set up Go - uses: actions/setup-go@v1 + uses: actions/setup-go@v4 with: - go-version: "1.18" - id: go - - - name: Check out code - uses: actions/checkout@v2 + go-version-file: "go.mod" - name: Install Dependencies env: @@ -21,4 +20,9 @@ jobs: run: go mod download - name: Test - run: go test -tags unit -race ./... + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml index 3dac8a3..b89b2e1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,11 +1,6 @@ run: tests: true max-same-issues: 50 - skip-dirs: - - resources - - old - skip-files: - - cmd/protopkg/main.go output: print-issued-lines: false @@ -19,13 +14,12 @@ linters: - unconvert - goimports - unused - - vetshadow + - govet - nakedret - errcheck - revive - ineffassign - goconst - - vet - unparam - gofmt @@ -45,14 +39,19 @@ linters-settings: - ifElseChain gofmt: rewrite-rules: - - pattern: 'interface{}' - replacement: 'any' - - pattern: 'a[b:len(a)]' - replacement: 'a[b:]' + - pattern: "interface{}" + replacement: "any" + - pattern: "a[b:len(a)]" + replacement: "a[b:]" issues: max-per-linter: 0 max-same: 0 + exclude-dirs: + - resources + - old + exclude-files: + - cmd/protopkg/main.go exclude-use-default: false exclude: # Captured by errcheck. diff --git a/README.md b/README.md index 1a68a09..27b362e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Test Go](https://github.com/invopop/jsonschema/actions/workflows/test.yaml/badge.svg)](https://github.com/invopop/jsonschema/actions/workflows/test.yaml) [![Go Report Card](https://goreportcard.com/badge/github.com/invopop/jsonschema)](https://goreportcard.com/report/github.com/invopop/jsonschema) [![GoDoc](https://godoc.org/github.com/invopop/jsonschema?status.svg)](https://godoc.org/github.com/invopop/jsonschema) +[![codecov](https://codecov.io/gh/invopop/jsonschema/graph/badge.svg?token=JMEB8W8GNZ)](https://codecov.io/gh/invopop/jsonschema) ![Latest Tag](https://img.shields.io/github/v/tag/invopop/jsonschema) This package can be used to generate [JSON Schemas](http://json-schema.org/latest/json-schema-validation.html) from Go types through reflection. @@ -52,10 +53,10 @@ jsonschema.Reflect(&TestUser{}) ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/invopop/jsonschema_test/sample-user", - "$ref": "#/$defs/SampleUser", + "$id": "https://github.com/invopop/jsonschema_test/test-user", + "$ref": "#/$defs/TestUser", "$defs": { - "SampleUser": { + "TestUser": { "oneOf": [ { "required": ["birth_date"], diff --git a/fixtures/go_comments_full.json b/fixtures/go_comments_full.json new file mode 100644 index 0000000..d1a5219 --- /dev/null +++ b/fixtures/go_comments_full.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/invopop/jsonschema/examples/user", + "$ref": "#/$defs/User", + "$defs": { + "NamedPets": { + "additionalProperties": { + "$ref": "#/$defs/Pet" + }, + "type": "object", + "description": "NamedPets is a map of animal names to pets." + }, + "Pet": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the animal." + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ], + "description": "Pet defines the user's fury friend." + }, + "Pets": { + "items": { + "$ref": "#/$defs/Pet" + }, + "type": "array", + "description": "Pets is a collection of Pet objects." + }, + "Plant": { + "properties": { + "variant": { + "type": "string", + "title": "Variant", + "description": "This comment will be used" + }, + "multicellular": { + "type": "boolean", + "title": "Multicellular", + "description": "Multicellular is true if the plant is multicellular" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "variant" + ], + "description": "Plant represents the plants the user might have and serves as a test\nof structs inside a `type` set." + }, + "User": { + "properties": { + "id": { + "type": "integer", + "description": "Unique sequential identifier." + }, + "name": { + "type": "string", + "maxLength": 20, + "minLength": 1, + "pattern": ".*", + "title": "the name", + "description": "this is a property", + "default": "alex", + "examples": [ + "joe", + "lucy" + ] + }, + "friends": { + "items": { + "type": "integer" + }, + "type": "array", + "description": "list of IDs, omitted when empty" + }, + "tags": { + "type": "object" + }, + "pets": { + "$ref": "#/$defs/Pets", + "description": "An array of pets the user cares for." + }, + "named_pets": { + "$ref": "#/$defs/NamedPets", + "description": "Set of animal names to pets" + }, + "plants": { + "items": { + "$ref": "#/$defs/Plant" + }, + "type": "array", + "title": "Plants", + "description": "Set of plants that the user likes" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "pets", + "named_pets", + "plants" + ], + "description": "User is used as a base to provide tests for comments.\nDon't forget to checkout the nested path." + } + } +} \ No newline at end of file diff --git a/reflect.go b/reflect.go index 0be6cfe..b39af10 100644 --- a/reflect.go +++ b/reflect.go @@ -1098,7 +1098,7 @@ func (t *Schema) MarshalJSON() ([]byte, error) { if err != nil { return nil, err } - if t.Extras == nil || len(t.Extras) == 0 { + if len(t.Extras) == 0 { return b, nil } m, err := json.Marshal(t.Extras) @@ -1149,13 +1149,3 @@ func splitOnUnescapedCommas(tagString string) []string { func fullyQualifiedTypeName(t reflect.Type) string { return t.PkgPath() + "." + t.Name() } - -// AddGoComments will update the reflectors comment map with all the comments -// found in the provided source directories. See the #ExtractGoComments method -// for more details. -func (r *Reflector) AddGoComments(base, path string) error { - if r.CommentMap == nil { - r.CommentMap = make(map[string]string) - } - return ExtractGoComments(base, path, r.CommentMap) -} diff --git a/comment_extractor.go b/reflect_comments.go similarity index 50% rename from comment_extractor.go rename to reflect_comments.go index e157837..eaa498a 100644 --- a/comment_extractor.go +++ b/reflect_comments.go @@ -13,18 +13,49 @@ import ( "go/token" ) -// ExtractGoComments will read all the go files contained in the provided path, -// including sub-directories, in order to generate a dictionary of comments -// associated with Types and Fields. The results will be added to the `commentsMap` -// provided in the parameters and expected to be used for Schema "description" fields. +type commentOptions struct { + fullObjectText bool // use the first sentence only? +} + +// CommentOption allows for special configuration options when preparing Go +// source files for comment extraction. +type CommentOption func(*commentOptions) + +// WithFullComment will configure the comment extraction to process to use an +// object type's full comment text instead of just the synopsis. +func WithFullComment() CommentOption { + return func(o *commentOptions) { + o.fullObjectText = true + } +} + +// AddGoComments will update the reflectors comment map with all the comments +// found in the provided source directories including sub-directories, in order to +// generate a dictionary of comments associated with Types and Fields. The results +// will be added to the `Reflect.CommentMap` ready to use with Schema "description" +// fields. // // The `go/parser` library is used to extract all the comments and unfortunately doesn't -// have a built-in way to determine the fully qualified name of a package. The `base` paremeter, -// the URL used to import that package, is thus required to be able to match reflected types. +// have a built-in way to determine the fully qualified name of a package. The `base` +// parameter, the URL used to import that package, is thus required to be able to match +// reflected types. // -// When parsing type comments, we use the `go/doc`'s Synopsis method to extract the first phrase -// only. Field comments, which tend to be much shorter, will include everything. -func ExtractGoComments(base, path string, commentMap map[string]string) error { +// When parsing type comments, by default we use the `go/doc`'s Synopsis method to extract +// the first phrase only. Field comments, which tend to be much shorter, will include everything. +// This behavior can be changed by using the `WithFullComment` option. +func (r *Reflector) AddGoComments(base, path string, opts ...CommentOption) error { + if r.CommentMap == nil { + r.CommentMap = make(map[string]string) + } + co := new(commentOptions) + for _, opt := range opts { + opt(co) + } + + return r.extractGoComments(base, path, r.CommentMap, co) +} + +func (r *Reflector) extractGoComments(base, path string, commentMap map[string]string, opts *commentOptions) error { fset := token.NewFileSet() dict := make(map[string][]*ast.Package) err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error { @@ -64,7 +95,9 @@ func ExtractGoComments(base, path string, commentMap map[string]string) error { txt = gtxt gtxt = "" } - txt = doc.Synopsis(txt) + if !opts.fullObjectText { + txt = doc.Synopsis(txt) + } commentMap[fmt.Sprintf("%s.%s", pkg, typ)] = strings.TrimSpace(txt) } case *ast.Field: diff --git a/reflect_comments_test.go b/reflect_comments_test.go new file mode 100644 index 0000000..e162b2b --- /dev/null +++ b/reflect_comments_test.go @@ -0,0 +1,37 @@ +package jsonschema + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/invopop/jsonschema/examples" + "github.com/stretchr/testify/require" +) + +func TestCommentsSchemaGeneration(t *testing.T) { + tests := []struct { + typ any + reflector *Reflector + fixture string + }{ + {&examples.User{}, prepareCommentReflector(t), "fixtures/go_comments.json"}, + {&examples.User{}, prepareCommentReflector(t, WithFullComment()), "fixtures/go_comments_full.json"}, + } + for _, tt := range tests { + name := strings.TrimSuffix(filepath.Base(tt.fixture), ".json") + t.Run(name, func(t *testing.T) { + compareSchemaOutput(t, + tt.fixture, tt.reflector, tt.typ, + ) + }) + } +} + +func prepareCommentReflector(t *testing.T, opts ...CommentOption) *Reflector { + t.Helper() + r := new(Reflector) + err := r.AddGoComments("github.com/invopop/jsonschema", "./examples", opts...) + require.NoError(t, err, "did not expect error while adding comments") + return r +} diff --git a/reflect_test.go b/reflect_test.go index 37ea18a..93bee67 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -13,8 +13,6 @@ import ( "testing" "time" - "github.com/invopop/jsonschema/examples" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -429,7 +427,7 @@ func TestSchemaGeneration(t *testing.T) { {&MinValue{}, &Reflector{}, "fixtures/schema_with_minimum.json"}, {&TestNullable{}, &Reflector{}, "fixtures/nullable.json"}, {&GrandfatherType{}, &Reflector{ - AdditionalFields: func(r reflect.Type) []reflect.StructField { + AdditionalFields: func(_ reflect.Type) []reflect.StructField { return []reflect.StructField{ { Name: "Addr", @@ -446,7 +444,6 @@ func TestSchemaGeneration(t *testing.T) { {&CustomMapOuter{}, &Reflector{}, "fixtures/custom_map_type.json"}, {&CustomTypeFieldWithInterface{}, &Reflector{}, "fixtures/custom_type_with_interface.json"}, {&PatternTest{}, &Reflector{}, "fixtures/commas_in_pattern.json"}, - {&examples.User{}, prepareCommentReflector(t), "fixtures/go_comments.json"}, {&RecursiveExample{}, &Reflector{}, "fixtures/recursive.json"}, {&KeyNamed{}, &Reflector{ KeyNamer: func(s string) string { @@ -488,14 +485,6 @@ func TestSchemaGeneration(t *testing.T) { } } -func prepareCommentReflector(t *testing.T) *Reflector { - t.Helper() - r := new(Reflector) - err := r.AddGoComments("github.com/invopop/jsonschema", "./examples") - require.NoError(t, err, "did not expect error while adding comments") - return r -} - func TestBaselineUnmarshal(t *testing.T) { r := &Reflector{} compareSchemaOutput(t, "fixtures/test_user.json", r, &TestUser{})