From 0c9a48994497208cce112fe1b3c57f6aa6ca6e7f Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 10 Jul 2026 07:47:02 -0700 Subject: [PATCH] feat: struct field tags customization Adds an output-options.struct-tags configuration that makes struct tag generation user-configurable instead of hardcoded. Each entry is a tag name plus a Go text/template rendered against per-field data (.FieldName, .IsOptional, .OmitEmpty, .OmitZero, .NeedsFormTag); entries merge by name on top of the defaults, so users can override the built-in json/form templates or add new tags (yaml, db, validate, ...). A template that renders empty suppresses the tag. The default templates reproduce the existing hardcoded output byte-for-byte, so generation is unchanged unless struct-tags is set. The legacy yaml-tags flag is superseded: an explicit yaml entry in struct-tags takes precedence over it. Extension-driven behavior (x-go-json-ignore, x-oapi-codegen-extra-tags) is applied on top of the rendered tags as before. Invalid templates are reported as configuration errors at validation/generation time. Partially addresses #2368. Co-Authored-By: Claude Fable 5 --- configuration-schema.json | 25 ++- docs/configuration.md | 17 ++ internal/test/options/struct_tags/config.yaml | 21 ++ internal/test/options/struct_tags/doc.go | 8 + internal/test/options/struct_tags/spec.yaml | 45 ++++ .../options/struct_tags/struct_tags.gen.go | 16 ++ .../options/struct_tags/struct_tags_test.go | 48 +++++ pkg/codegen/codegen.go | 25 +++ pkg/codegen/configuration.go | 18 +- pkg/codegen/operations.go | 20 +- pkg/codegen/schema.go | 26 +-- pkg/codegen/structtags.go | 203 ++++++++++++++++++ pkg/codegen/structtags_test.go | 163 ++++++++++++++ 13 files changed, 604 insertions(+), 31 deletions(-) create mode 100644 internal/test/options/struct_tags/config.yaml create mode 100644 internal/test/options/struct_tags/doc.go create mode 100644 internal/test/options/struct_tags/spec.yaml create mode 100644 internal/test/options/struct_tags/struct_tags.gen.go create mode 100644 internal/test/options/struct_tags/struct_tags_test.go create mode 100644 pkg/codegen/structtags.go create mode 100644 pkg/codegen/structtags_test.go diff --git a/configuration-schema.json b/configuration-schema.json index b80019e64..ea198c137 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -263,7 +263,30 @@ }, "yaml-tags": { "type": "boolean", - "description": "Enable the generation of YAML tags for struct fields" + "description": "Enable the generation of YAML tags for struct fields. Superseded by `struct-tags`: when a `yaml` entry is present there, this flag has no effect." + }, + "struct-tags": { + "type": "object", + "description": "Configure which struct tags are generated on struct fields and how their values are rendered. Entries are merged by name on top of the defaults (json, form), so a `json` entry replaces the default template while new names add tags. Tags render in alphabetical order.", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The struct tag name, e.g. `json`, `yaml`, `db`" + }, + "template": { + "type": "string", + "description": "Go text/template producing the tag value. Available variables: `.FieldName`, `.IsOptional`, `.OmitEmpty`, `.OmitZero`, `.NeedsFormTag`. A template that renders to the empty string suppresses the tag on that field." + } + }, + "required": ["name", "template"] + } + } + } }, "client-response-bytes-function": { "type": "boolean", diff --git a/docs/configuration.md b/docs/configuration.md index 2bd0d3075..69fd973b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -124,7 +124,24 @@ output-options: json: { type: json.RawMessage, import: encoding/json } uuid: { type: openapi_types.UUID } binary: { type: openapi_types.File } + # Superseded by struct-tags: a `yaml` entry there takes precedence over this flag yaml-tags: false + # Configure generated struct tags. Entries are merged by name on top of the + # defaults shown below, so redefining `json` replaces its template while new + # names add tags. Values are Go text/templates over the variables + # .FieldName, .IsOptional, .OmitEmpty, .OmitZero and .NeedsFormTag; a + # template that renders empty suppresses the tag on that field. Tags are + # emitted in alphabetical order. Per-field x-oapi-codegen-extra-tags and + # x-go-json-ignore are applied on top of the rendered tags. + struct-tags: + tags: + - name: json + template: '{{.FieldName}}{{if .OmitEmpty}},omitempty{{end}}{{if .OmitZero}},omitzero{{end}}' + - name: form + template: '{{if .NeedsFormTag}}{{.FieldName}}{{if .OmitEmpty}},omitempty{{end}}{{end}}' + # Additional tags can be added, e.g.: + # - name: db + # template: '{{.FieldName}}' client-response-bytes-function: false skip-client-response-content-type: false skip-response-body-getters: false diff --git a/internal/test/options/struct_tags/config.yaml b/internal/test/options/struct_tags/config.yaml new file mode 100644 index 000000000..9a69cb720 --- /dev/null +++ b/internal/test/options/struct_tags/config.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: optionsstructtags +output: struct_tags.gen.go +generate: + models: true +output-options: + skip-prune: true + # yaml-tags is superseded by the explicit yaml entry in struct-tags below: + # the entry's template wins over the flag's injected default. + yaml-tags: true + # struct-tags: user-configurable struct tag generation. Entries merge by + # name on top of the defaults (json, form), so the yaml entry overrides + # the yaml-tags default while validate/db add new tags. + struct-tags: + tags: + - name: yaml + template: '{{.FieldName}}' + - name: validate + template: '{{if not .IsOptional}}required{{end}}' + - name: db + template: '{{.FieldName}}' diff --git a/internal/test/options/struct_tags/doc.go b/internal/test/options/struct_tags/doc.go new file mode 100644 index 000000000..b44553557 --- /dev/null +++ b/internal/test/options/struct_tags/doc.go @@ -0,0 +1,8 @@ +// Package optionsstructtags exercises the struct-tags output option: struct +// tag generation is user-configurable via Go text/templates. The config for +// this package overrides the yaml tag template (superseding the yaml-tags +// flag), and adds validate and db tags, while keeping the default json and +// form templates. +package optionsstructtags + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/options/struct_tags/spec.yaml b/internal/test/options/struct_tags/spec.yaml new file mode 100644 index 000000000..8bf6e704c --- /dev/null +++ b/internal/test/options/struct_tags/spec.yaml @@ -0,0 +1,45 @@ +# ========================================================================== +# Category: options/struct_tags +# Tests: struct-tags output option: user-configurable struct tag templates. +# - default json template kept when not overridden +# - user yaml entry supersedes the yaml-tags flag (no omitempty) +# - added tags (validate, db) rendered on every field +# - form tag still gated on form-style parameters (NeedsFormTag) +# ========================================================================== +openapi: "3.0.1" +info: + title: options/struct_tags + version: "1.0.0" + description: | + Exercises the struct-tags output option, which makes struct tag + generation user-configurable via Go text/templates. +paths: + /pets: + get: + operationId: listPets + parameters: + # form-style query parameter: field must carry a form tag + - name: limit + description: Query parameter + in: query + required: false + schema: + type: integer + responses: + "200": + description: pets + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" +components: + schemas: + Pet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string diff --git a/internal/test/options/struct_tags/struct_tags.gen.go b/internal/test/options/struct_tags/struct_tags.gen.go new file mode 100644 index 000000000..210c61c00 --- /dev/null +++ b/internal/test/options/struct_tags/struct_tags.gen.go @@ -0,0 +1,16 @@ +// Package optionsstructtags provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package optionsstructtags + +// Pet defines model for Pet. +type Pet struct { + Name string `db:"name" json:"name" validate:"required" yaml:"name"` + Tag *string `db:"tag" json:"tag,omitempty" yaml:"tag"` +} + +// ListPetsParams defines parameters for ListPets. +type ListPetsParams struct { + // Limit Query parameter + Limit *int `db:"limit" form:"limit,omitempty" json:"limit,omitempty" yaml:"limit"` +} diff --git a/internal/test/options/struct_tags/struct_tags_test.go b/internal/test/options/struct_tags/struct_tags_test.go new file mode 100644 index 000000000..61c160cd1 --- /dev/null +++ b/internal/test/options/struct_tags/struct_tags_test.go @@ -0,0 +1,48 @@ +package optionsstructtags + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fieldTag returns the struct tag of the named field. +func fieldTag(t *testing.T, typ reflect.Type, field string) reflect.StructTag { + t.Helper() + f, ok := typ.FieldByName(field) + require.True(t, ok, "field %s not found on %s", field, typ) + return f.Tag +} + +func TestStructTags(t *testing.T) { + pet := reflect.TypeOf(Pet{}) + + t.Run("default json template is kept", func(t *testing.T) { + assert.Equal(t, "name", fieldTag(t, pet, "Name").Get("json")) + assert.Equal(t, "tag,omitempty", fieldTag(t, pet, "Tag").Get("json")) + }) + + t.Run("user yaml entry supersedes yaml-tags flag", func(t *testing.T) { + // The overridden template has no omitempty, unlike the yaml-tags default. + assert.Equal(t, "name", fieldTag(t, pet, "Name").Get("yaml")) + assert.Equal(t, "tag", fieldTag(t, pet, "Tag").Get("yaml")) + }) + + t.Run("added tags are rendered", func(t *testing.T) { + assert.Equal(t, "name", fieldTag(t, pet, "Name").Get("db")) + assert.Equal(t, "required", fieldTag(t, pet, "Name").Get("validate")) + // Empty render suppresses the tag entirely on optional fields. + _, hasValidate := fieldTag(t, pet, "Tag").Lookup("validate") + assert.False(t, hasValidate) + }) + + t.Run("form tag still gated on form-style parameters", func(t *testing.T) { + params := reflect.TypeOf(ListPetsParams{}) + assert.Equal(t, "limit,omitempty", fieldTag(t, params, "Limit").Get("form")) + // Schema fields are not form-bound, so no form tag is emitted. + _, hasForm := fieldTag(t, pet, "Name").Lookup("form") + assert.False(t, hasForm) + }) +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index ea29b723e..e7216beed 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -74,6 +74,15 @@ var globalState struct { // streamingContentTypeRegexes are the compiled regexes (defaults + user) // used by ResponseContentDefinition.IsStreamingContentType. streamingContentTypeRegexes []*regexp.Regexp + // schemaFieldTagGenerator renders struct tags for schema property + // fields (defaults + output-options.struct-tags, including the legacy + // yaml-tags injection). Built in Generate; lazily built for direct + // callers via schemaFieldTagGenerator(). + schemaFieldTagGenerator *structTagGenerator + // paramFieldTagGenerator renders struct tags for path parameter fields + // on strict RequestObject structs; identical to the schema generator + // except the legacy yaml-tags flag does not apply. + paramFieldTagGenerator *structTagGenerator } // goImport represents a go package to be imported in the generated code @@ -166,6 +175,22 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { globalState.typeMapping = DefaultTypeMapping } + // Build the struct tag generators eagerly so invalid user templates in + // output-options.struct-tags are reported as errors instead of being + // skipped. Assigning here also resets any state from a prior Generate. + schemaTagGen, err := newStructTagGenerator( + defaultStructTagsConfig(opts.OutputOptions.EnableYamlTags).Merge(opts.OutputOptions.StructTags)) + if err != nil { + return "", fmt.Errorf("error in output-options.struct-tags: %w", err) + } + globalState.schemaFieldTagGenerator = schemaTagGen + paramTagGen, err := newStructTagGenerator( + defaultStructTagsConfig(false).Merge(opts.OutputOptions.StructTags)) + if err != nil { + return "", fmt.Errorf("error in output-options.struct-tags: %w", err) + } + globalState.paramFieldTagGenerator = paramTagGen + filterOperationsByTag(spec, opts) filterOperationsByOperationID(spec, opts) if !opts.OutputOptions.SkipPrune { diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index 88c0a39c8..a8a044832 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -433,9 +433,19 @@ type OutputOptions struct { // Overlay defines configuration for the OpenAPI Overlay (https://github.com/OAI/Overlay-Specification) to manipulate the OpenAPI specification before generation. This allows modifying the specification without needing to apply changes directly to it, making it easier to keep it up-to-date. Overlay OutputOptionsOverlay `yaml:"overlay"` - // EnableYamlTags adds YAML tags to generated structs, in addition to default JSON ones + // EnableYamlTags adds YAML tags to generated structs, in addition to default JSON ones. + // Superseded by StructTags: when a `yaml` entry is present in `struct-tags`, this flag + // has no effect. EnableYamlTags bool `yaml:"yaml-tags,omitempty"` + // StructTags configures which struct tags are generated on struct fields and how their + // values are rendered. Each entry is a tag name plus a Go text/template evaluated + // against the field (see StructTagInfo for the available variables). Entries are + // merged by name on top of the defaults, so a `json` entry replaces the default json + // template while new names add tags. Tags render in alphabetical order; a template + // that renders to the empty string suppresses that tag on that field. + StructTags StructTagsConfig `yaml:"struct-tags,omitempty"` + // ClientResponseBytesFunction decides whether to enable the generation of a `Bytes()` method on response objects for `ClientWithResponses` ClientResponseBytesFunction bool `yaml:"client-response-bytes-function,omitempty"` @@ -509,6 +519,12 @@ func (oo OutputOptions) Validate() map[string]string { } } + if _, err := newStructTagGenerator(defaultStructTagsConfig(oo.EnableYamlTags).Merge(oo.StructTags)); err != nil { + return map[string]string{ + "struct-tags": err.Error(), + } + } + return nil } diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 2235c5ceb..f753918dc 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -69,19 +69,19 @@ func (pd ParameterDefinition) ZeroValueIsNil() bool { return strings.HasPrefix(pd.Schema.GoType, "map[") } -// JsonTag generates the JSON annotation to map GoType to json type name. If Parameter -// Foo is marshaled to json as "foo", this will create the annotation -// 'json:"foo"' +// JsonTag generates the struct tag annotation for a parameter field on a +// strict RequestObject struct. If Parameter Foo is marshaled to json as "foo", +// this will create the annotation 'json:"foo"'. Tags configured via +// output-options.struct-tags are rendered too (the legacy yaml-tags flag does +// not apply to these fields). // It also includes any additional struct tags from x-oapi-codegen-extra-tags // at the parameter or schema level (parameter-level takes precedence). func (pd *ParameterDefinition) JsonTag() string { - fieldTags := make(map[string]string) - - if pd.Required { - fieldTags["json"] = pd.ParamName - } else { - fieldTags["json"] = pd.ParamName + ",omitempty" - } + fieldTags := paramFieldTagGenerator().generateTagsMap(StructTagInfo{ + FieldName: pd.ParamName, + IsOptional: !pd.Required, + OmitEmpty: !pd.Required, + }) // Merge x-oapi-codegen-extra-tags from schema level first, then parameter level // so that parameter-level takes precedence. diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 0081fa41a..941a287ff 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1219,13 +1219,6 @@ type FieldDescriptor struct { IsRef bool // Is this schema a reference to predefined object? } -func stringOrEmpty(b bool, s string) string { - if b { - return s - } - return "" -} - // GenFieldsFromProperties produce corresponding field names with JSON annotations, // given a list of schema descriptors func GenFieldsFromProperties(props []Property) []string { @@ -1292,18 +1285,13 @@ func GenFieldsFromProperties(props []Property) []string { } } - fieldTags := make(map[string]string) - - fieldTags["json"] = p.JsonFieldName + - stringOrEmpty(omitEmpty, ",omitempty") + - stringOrEmpty(omitZero, ",omitzero") - - if globalState.options.OutputOptions.EnableYamlTags { - fieldTags["yaml"] = p.JsonFieldName + stringOrEmpty(omitEmpty, ",omitempty") - } - if p.NeedsFormTag { - fieldTags["form"] = p.JsonFieldName + stringOrEmpty(omitEmpty, ",omitempty") - } + fieldTags := schemaFieldTagGenerator().generateTagsMap(StructTagInfo{ + FieldName: p.JsonFieldName, + IsOptional: !p.Required, + OmitEmpty: omitEmpty, + OmitZero: omitZero, + NeedsFormTag: p.NeedsFormTag, + }) // Support x-go-json-ignore if extension, ok := p.Extensions[extPropGoJsonIgnore]; ok { diff --git a/pkg/codegen/structtags.go b/pkg/codegen/structtags.go new file mode 100644 index 000000000..e9303a1b0 --- /dev/null +++ b/pkg/codegen/structtags.go @@ -0,0 +1,203 @@ +package codegen + +import ( + "bytes" + "fmt" + "os" + "text/template" +) + +// StructTagInfo is the data made available to struct tag templates. The +// omit flags are fully computed before rendering: OmitEmpty folds in the +// required/readOnly/writeOnly logic, compatibility options and the +// x-omitempty extension; OmitZero folds in x-omitzero and +// prefer-skip-optional-pointer-with-omitzero. Templates therefore only +// need to decide whether to emit them, not re-derive them. +type StructTagInfo struct { + // FieldName is the property name from the OpenAPI spec. + FieldName string + // IsOptional is true when the field is not listed as required. + IsOptional bool + // OmitEmpty is true when the field should carry ",omitempty". + OmitEmpty bool + // OmitZero is true when the field should carry ",omitzero". + OmitZero bool + // NeedsFormTag is true for fields bound from form-style parameters or + // urlencoded request bodies. + NeedsFormTag bool +} + +// StructTagTemplate defines a single struct tag as a name plus a Go +// text/template rendered against StructTagInfo. A template that renders +// to the empty string suppresses the tag on that field. +type StructTagTemplate struct { + // Name is the tag name (e.g. "json", "yaml", "db"). + Name string `yaml:"name"` + // Template is a Go text/template producing the tag value. + // Available fields: .FieldName, .IsOptional, .OmitEmpty, .OmitZero, + // .NeedsFormTag. + Template string `yaml:"template"` +} + +// StructTagsConfig configures struct tag generation for generated fields. +type StructTagsConfig struct { + // Tags lists the tags to generate. Entries are merged by name on top + // of the defaults: a matching name replaces the default template, a + // new name adds a tag. Output ordering is always alphabetical by name. + Tags []StructTagTemplate `yaml:"tags,omitempty"` +} + +const ( + defaultJSONTagTemplate = `{{.FieldName}}{{if .OmitEmpty}},omitempty{{end}}{{if .OmitZero}},omitzero{{end}}` + defaultFormTagTemplate = `{{if .NeedsFormTag}}{{.FieldName}}{{if .OmitEmpty}},omitempty{{end}}{{end}}` + defaultYamlTagTemplate = `{{.FieldName}}{{if .OmitEmpty}},omitempty{{end}}` +) + +// defaultStructTagsConfig returns the built-in tag templates, which +// reproduce the historical hardcoded output. The yaml entry is only +// present when the legacy `output-options.yaml-tags` flag is enabled and +// applies only where it always did (schema properties); a user-supplied +// yaml entry in struct-tags supersedes the flag entirely. +func defaultStructTagsConfig(enableYamlTags bool) StructTagsConfig { + tags := []StructTagTemplate{ + {Name: "json", Template: defaultJSONTagTemplate}, + {Name: "form", Template: defaultFormTagTemplate}, + } + if enableYamlTags { + tags = append(tags, StructTagTemplate{Name: "yaml", Template: defaultYamlTagTemplate}) + } + return StructTagsConfig{Tags: tags} +} + +// Merge merges user config on top of this config by tag name: matching +// names override the default template, new names are appended. +func (c StructTagsConfig) Merge(other StructTagsConfig) StructTagsConfig { + if len(other.Tags) == 0 { + return c + } + merged := make(map[string]StructTagTemplate, len(c.Tags)+len(other.Tags)) + order := make([]string, 0, len(c.Tags)+len(other.Tags)) + for _, t := range c.Tags { + merged[t.Name] = t + order = append(order, t.Name) + } + for _, t := range other.Tags { + if _, exists := merged[t.Name]; !exists { + order = append(order, t.Name) + } + merged[t.Name] = t + } + result := StructTagsConfig{Tags: make([]StructTagTemplate, 0, len(order))} + for _, name := range order { + result.Tags = append(result.Tags, merged[name]) + } + return result +} + +// structTagGenerator renders struct tag values from parsed templates. +type structTagGenerator struct { + templates []tagTemplate +} + +type tagTemplate struct { + name string + tmpl *template.Template +} + +// newStructTagGenerator parses the configured templates. Each template is +// also rendered against every combination of StructTagInfo's boolean +// fields so that execute-time errors (e.g. references to unknown fields), +// including ones gated behind conditionals, are reported at configuration +// time rather than silently dropping tags during generation. +func newStructTagGenerator(config StructTagsConfig) (*structTagGenerator, error) { + g := &structTagGenerator{ + templates: make([]tagTemplate, 0, len(config.Tags)), + } + // Keep numFlags in sync with the boolean fields of StructTagInfo. + const numFlags = 4 + probes := make([]StructTagInfo, 0, 1< tag value. Extension +// driven overrides (x-go-json-ignore, x-oapi-codegen-extra-tags) are +// applied by the callers on top of this map. +func (g *structTagGenerator) generateTagsMap(info StructTagInfo) map[string]string { + result := make(map[string]string, len(g.templates)) + for _, t := range g.templates { + var buf bytes.Buffer + if err := t.tmpl.Execute(&buf, info); err != nil { + // Templates are validated at construction time against every + // combination of boolean fields, so a failure here is + // unexpected. The tag is skipped rather than emitting a partial + // value, with a warning so the omission is not silent. + fmt.Fprintf(os.Stderr, "Warning: struct tag template %q failed for field %q, omitting the tag: %v\n", t.name, info.FieldName, err) + continue + } + if value := buf.String(); value != "" { + result[t.name] = value + } + } + return result +} + +// schemaFieldTagGenerator returns the generator used for schema property +// fields, building it from the current configuration on first use. This +// lazy path keeps direct callers of GenFieldsFromProperties (tests) +// working without running Generate; Generate itself constructs the +// generators eagerly so configuration errors are reported. +func schemaFieldTagGenerator() *structTagGenerator { + if globalState.schemaFieldTagGenerator == nil { + cfg := defaultStructTagsConfig(globalState.options.OutputOptions.EnableYamlTags). + Merge(globalState.options.OutputOptions.StructTags) + g, err := newStructTagGenerator(cfg) + if err != nil { + // Generate() reports this error to the user; fall back to the + // defaults so lazy callers still produce standard tags. + g, _ = newStructTagGenerator(defaultStructTagsConfig(globalState.options.OutputOptions.EnableYamlTags)) + } + globalState.schemaFieldTagGenerator = g + } + return globalState.schemaFieldTagGenerator +} + +// paramFieldTagGenerator returns the generator used for path parameter +// fields on strict RequestObject structs (ParameterDefinition.JsonTag). +// It differs from the schema one in a single way: the legacy `yaml-tags` +// flag never applied to those fields, so its injected default is excluded +// here. Tags configured explicitly via struct-tags apply to them too. +func paramFieldTagGenerator() *structTagGenerator { + if globalState.paramFieldTagGenerator == nil { + cfg := defaultStructTagsConfig(false). + Merge(globalState.options.OutputOptions.StructTags) + g, err := newStructTagGenerator(cfg) + if err != nil { + g, _ = newStructTagGenerator(defaultStructTagsConfig(false)) + } + globalState.paramFieldTagGenerator = g + } + return globalState.paramFieldTagGenerator +} diff --git a/pkg/codegen/structtags_test.go b/pkg/codegen/structtags_test.go new file mode 100644 index 000000000..34e18a489 --- /dev/null +++ b/pkg/codegen/structtags_test.go @@ -0,0 +1,163 @@ +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultStructTagsConfig(t *testing.T) { + t.Run("without yaml-tags", func(t *testing.T) { + cfg := defaultStructTagsConfig(false) + require.Len(t, cfg.Tags, 2) + assert.Equal(t, "json", cfg.Tags[0].Name) + assert.Equal(t, "form", cfg.Tags[1].Name) + }) + + t.Run("with yaml-tags", func(t *testing.T) { + cfg := defaultStructTagsConfig(true) + require.Len(t, cfg.Tags, 3) + assert.Equal(t, "yaml", cfg.Tags[2].Name) + }) +} + +func TestStructTagsConfigMerge(t *testing.T) { + t.Run("empty user config keeps defaults", func(t *testing.T) { + merged := defaultStructTagsConfig(false).Merge(StructTagsConfig{}) + assert.Equal(t, defaultStructTagsConfig(false), merged) + }) + + t.Run("matching name overrides default", func(t *testing.T) { + merged := defaultStructTagsConfig(false).Merge(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "json", Template: `{{.FieldName}}`}}, + }) + require.Len(t, merged.Tags, 2) + assert.Equal(t, "json", merged.Tags[0].Name) + assert.Equal(t, `{{.FieldName}}`, merged.Tags[0].Template) + assert.Equal(t, defaultFormTagTemplate, merged.Tags[1].Template) + }) + + t.Run("new name is appended", func(t *testing.T) { + merged := defaultStructTagsConfig(false).Merge(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "db", Template: `{{.FieldName}}`}}, + }) + require.Len(t, merged.Tags, 3) + assert.Equal(t, "db", merged.Tags[2].Name) + }) + + t.Run("user yaml entry clobbers the yaml-tags injected default", func(t *testing.T) { + merged := defaultStructTagsConfig(true).Merge(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "yaml", Template: `{{.FieldName}}`}}, + }) + require.Len(t, merged.Tags, 3) + assert.Equal(t, "yaml", merged.Tags[2].Name) + assert.Equal(t, `{{.FieldName}}`, merged.Tags[2].Template) + }) +} + +func TestNewStructTagGenerator(t *testing.T) { + t.Run("defaults parse", func(t *testing.T) { + _, err := newStructTagGenerator(defaultStructTagsConfig(true)) + require.NoError(t, err) + }) + + t.Run("parse error is reported", func(t *testing.T) { + _, err := newStructTagGenerator(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "json", Template: `{{.FieldName`}}, + }) + require.ErrorContains(t, err, `invalid struct tag template for "json"`) + }) + + t.Run("execute error is reported", func(t *testing.T) { + _, err := newStructTagGenerator(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "json", Template: `{{.NoSuchField}}`}}, + }) + require.ErrorContains(t, err, `struct tag template for "json" failed to render`) + }) + + t.Run("execute error inside a conditional branch is reported", func(t *testing.T) { + _, err := newStructTagGenerator(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "json", Template: `{{if .IsOptional}}{{.NoSuchField}}{{end}}`}}, + }) + require.ErrorContains(t, err, `struct tag template for "json" failed to render`) + + _, err = newStructTagGenerator(StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "json", Template: `{{if not .NeedsFormTag}}{{.NoSuchField}}{{end}}`}}, + }) + require.ErrorContains(t, err, `struct tag template for "json" failed to render`) + }) +} + +func TestStructTagGeneratorGenerateTagsMap(t *testing.T) { + defaults, err := newStructTagGenerator(defaultStructTagsConfig(true)) + require.NoError(t, err) + + t.Run("required field", func(t *testing.T) { + tags := defaults.generateTagsMap(StructTagInfo{FieldName: "name"}) + assert.Equal(t, map[string]string{ + "json": "name", + "yaml": "name", + }, tags) + }) + + t.Run("optional field gets omitempty", func(t *testing.T) { + tags := defaults.generateTagsMap(StructTagInfo{ + FieldName: "name", + IsOptional: true, + OmitEmpty: true, + }) + assert.Equal(t, map[string]string{ + "json": "name,omitempty", + "yaml": "name,omitempty", + }, tags) + }) + + t.Run("omitzero only affects json", func(t *testing.T) { + tags := defaults.generateTagsMap(StructTagInfo{ + FieldName: "name", + OmitEmpty: true, + OmitZero: true, + }) + assert.Equal(t, map[string]string{ + "json": "name,omitempty,omitzero", + "yaml": "name,omitempty", + }, tags) + }) + + t.Run("form tag only rendered when NeedsFormTag", func(t *testing.T) { + tags := defaults.generateTagsMap(StructTagInfo{ + FieldName: "name", + NeedsFormTag: true, + }) + assert.Equal(t, map[string]string{ + "json": "name", + "yaml": "name", + "form": "name", + }, tags) + }) + + t.Run("empty render suppresses the tag", func(t *testing.T) { + g, err := newStructTagGenerator(StructTagsConfig{ + Tags: []StructTagTemplate{ + {Name: "validate", Template: `{{if not .IsOptional}}required{{end}}`}, + }, + }) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"validate": "required"}, + g.generateTagsMap(StructTagInfo{FieldName: "name"})) + assert.Empty(t, g.generateTagsMap(StructTagInfo{FieldName: "name", IsOptional: true})) + }) +} + +func TestOutputOptionsValidateStructTags(t *testing.T) { + oo := OutputOptions{ + StructTags: StructTagsConfig{ + Tags: []StructTagTemplate{{Name: "json", Template: `{{.FieldName`}}, + }, + } + problems := oo.Validate() + require.Contains(t, problems, "struct-tags") + assert.Contains(t, problems["struct-tags"], "invalid struct tag template") +}