diff --git a/configuration-schema.json b/configuration-schema.json index 5c16cabb..db71f6ce 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -126,6 +126,13 @@ "type": "boolean", "description": "AlwaysPrefixEnumValues specifies whether to always prefix enum values with the schema name. Defaults to true." }, + "additional-tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional struct tags to emit on generated fields, mirroring the json tag value. For example, [\"yaml\"] emits yaml:\"field_name,omitempty\" alongside the json tag. Useful when structs are decoded by libraries that don't read json tags (e.g. go.yaml.in/yaml/v4). Per-property x-oapi-codegen-extra-tags take priority over these global tags." + }, "validation": { "$ref": "#/definitions/ValidationOptions", "description": "Validation specifies options for Validate() method generation." diff --git a/examples/extensions/additionaltags/api.yaml b/examples/extensions/additionaltags/api.yaml new file mode 100644 index 00000000..02dc45bf --- /dev/null +++ b/examples/extensions/additionaltags/api.yaml @@ -0,0 +1,26 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: additional-tags +components: + schemas: + Issue: + type: object + required: + - id + - closed_at + properties: + id: + type: integer + closed_at: + type: string + format: date-time + label_name: + type: string + extra_override: + type: string + x-oapi-codegen-extra-tags: + yaml: custom_override + ignored_field: + type: string + x-go-json-ignore: true diff --git a/examples/extensions/additionaltags/cfg.yaml b/examples/extensions/additionaltags/cfg.yaml new file mode 100644 index 00000000..115692fd --- /dev/null +++ b/examples/extensions/additionaltags/cfg.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: additionaltags +# to make sure that all types are generated, even if they're unreferenced +skip-prune: true +generate: + client: false + additional-tags: + - yaml +output: + use-single-file: true diff --git a/examples/extensions/additionaltags/gen.go b/examples/extensions/additionaltags/gen.go new file mode 100644 index 00000000..62771eaa --- /dev/null +++ b/examples/extensions/additionaltags/gen.go @@ -0,0 +1,29 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package additionaltags + +import ( + "time" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +type Issue struct { + ID int `json:"id" yaml:"id"` + ClosedAt time.Time `json:"closed_at" validate:"required" yaml:"closed_at"` + LabelName *string `json:"label_name,omitempty" yaml:"label_name,omitempty"` + ExtraOverride *string `json:"extra_override,omitempty" yaml:"custom_override"` + IgnoredField *string `json:"-" yaml:"-"` +} + +func (i Issue) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(i)) +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/extensions/additionaltags/generate.go b/examples/extensions/additionaltags/generate.go new file mode 100644 index 00000000..3401bfea --- /dev/null +++ b/examples/extensions/additionaltags/generate.go @@ -0,0 +1,3 @@ +package additionaltags + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 8311c2f0..76837b13 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -94,10 +94,15 @@ func CreateParseContextFromModel(model *v3high.Document, cfg Configuration) (*Pa return nil, nil } + if err := cfg.Generate.Validate(); err != nil { + return nil, err + } + parseOptions := ParseOptions{ OmitDescription: cfg.Generate.OmitDescription, DefaultIntType: cfg.Generate.DefaultIntType, AlwaysPrefixEnumValues: cfg.Generate.AlwaysPrefixEnumValues, + AdditionalTags: cfg.Generate.AdditionalTags, SkipValidation: cfg.Generate.Validation.Skip, ErrorMapping: cfg.ErrorMapping, typeTracker: newTypeTracker(), diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index c714ffca..9dc209e7 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -12,6 +12,7 @@ package codegen import ( "fmt" + "strings" "time" ) @@ -170,6 +171,9 @@ func (o Configuration) OverwriteWith(other Configuration) Configuration { if other.Generate.AlwaysPrefixEnumValues { o.Generate.AlwaysPrefixEnumValues = other.Generate.AlwaysPrefixEnumValues } + if len(other.Generate.AdditionalTags) > 0 { + o.Generate.AdditionalTags = other.Generate.AdditionalTags + } // Overwrite Validation options if other.Generate.Validation.Skip { o.Generate.Validation.Skip = other.Generate.Validation.Skip @@ -312,10 +316,25 @@ type GenerateOptions struct { // AlwaysPrefixEnumValues specifies whether to always prefix enum values with the schema name. Defaults to true. AlwaysPrefixEnumValues bool `yaml:"always-prefix-enum-values"` + // AdditionalTags specifies additional struct tags to emit on generated fields, + // mirroring the json tag value. For example, ["yaml"] emits yaml:"field_name,omitempty" + // alongside the json tag. Per-property x-oapi-codegen-extra-tags take priority. + AdditionalTags []string `yaml:"additional-tags"` + // Validation specifies options for Validate() method generation. Validation ValidationOptions `yaml:"validation"` } +// Validate returns an error if any generate options are invalid. +func (o GenerateOptions) Validate() error { + for _, tag := range o.AdditionalTags { + if tag == "" || strings.ContainsAny(tag, " \t\"'`:") { + return fmt.Errorf("%w: %q", ErrInvalidAdditionalTag, tag) + } + } + return nil +} + type ValidationOptions struct { // Skip specifies whether to skip Validation method generation. Defaults to false. Skip bool `yaml:"skip"` diff --git a/pkg/codegen/configuration_test.go b/pkg/codegen/configuration_test.go index 3fa33785..5587832f 100644 --- a/pkg/codegen/configuration_test.go +++ b/pkg/codegen/configuration_test.go @@ -353,3 +353,35 @@ func TestBasePathOverwriteWith(t *testing.T) { assert.Equal(t, "/keep/this", result.BasePath) }) } + +func TestGenerateOptions_Validate(t *testing.T) { + t.Run("valid tags", func(t *testing.T) { + o := GenerateOptions{AdditionalTags: []string{"yaml", "toml", "mapstructure"}} + assert.NoError(t, o.Validate()) + }) + + t.Run("empty tag name", func(t *testing.T) { + o := GenerateOptions{AdditionalTags: []string{""}} + assert.ErrorIs(t, o.Validate(), ErrInvalidAdditionalTag) + }) + + t.Run("tag with space", func(t *testing.T) { + o := GenerateOptions{AdditionalTags: []string{"my tag"}} + assert.ErrorIs(t, o.Validate(), ErrInvalidAdditionalTag) + }) + + t.Run("tag with colon", func(t *testing.T) { + o := GenerateOptions{AdditionalTags: []string{"bad:tag"}} + assert.ErrorIs(t, o.Validate(), ErrInvalidAdditionalTag) + }) + + t.Run("tag with quote", func(t *testing.T) { + o := GenerateOptions{AdditionalTags: []string{`bad"tag`}} + assert.ErrorIs(t, o.Validate(), ErrInvalidAdditionalTag) + }) + + t.Run("nil tags is valid", func(t *testing.T) { + o := GenerateOptions{} + assert.NoError(t, o.Validate()) + }) +} diff --git a/pkg/codegen/errors.go b/pkg/codegen/errors.go index c5bb6881..69bfc817 100644 --- a/pkg/codegen/errors.go +++ b/pkg/codegen/errors.go @@ -35,4 +35,5 @@ var ( ErrHandlerKindUnsupported = errors.New("unsupported handler kind") ErrServerHandlerPackageRequired = errors.New("server handler-package is required when server generation is enabled") ErrServerRequiresService = errors.New("server generation requires service generation: add 'service: {}' to handler config") + ErrInvalidAdditionalTag = errors.New("invalid additional tag name: must not be empty or contain spaces, quotes, or colons") ) diff --git a/pkg/codegen/parser.go b/pkg/codegen/parser.go index ab3b7150..dc1f3f85 100644 --- a/pkg/codegen/parser.go +++ b/pkg/codegen/parser.go @@ -67,6 +67,7 @@ type ParseOptions struct { OmitDescription bool DefaultIntType string AlwaysPrefixEnumValues bool + AdditionalTags []string SkipValidation bool // ErrorMapping maps response type names to the field that should be used diff --git a/pkg/codegen/schema_property.go b/pkg/codegen/schema_property.go index dd576b19..30cdad7c 100644 --- a/pkg/codegen/schema_property.go +++ b/pkg/codegen/schema_property.go @@ -308,6 +308,13 @@ func genFieldsFromProperties(props []Property, options ParseOptions) []string { } } + // Emit additional tags (mirrors json tag value, but doesn't overwrite extra-tags) + for _, tag := range options.AdditionalTags { + if _, exists := fieldTags[tag]; !exists { + fieldTags[tag] = fieldTags["json"] + } + } + // Support x-sensitive-data - add a simple marker tag // The actual masking is handled via custom MarshalJSON generation if _, ok := p.Extensions[extSensitiveData]; ok { diff --git a/pkg/codegen/schema_property_test.go b/pkg/codegen/schema_property_test.go index c7dc8335..36326dce 100644 --- a/pkg/codegen/schema_property_test.go +++ b/pkg/codegen/schema_property_test.go @@ -89,6 +89,102 @@ func TestProperty_GoTypeDef(t *testing.T) { } } +func TestGenFieldsFromProperties_AdditionalTags(t *testing.T) { + tests := []struct { + name string + props []Property + options ParseOptions + contains []string + notContains []string + }{ + { + name: "yaml tag mirrors json tag", + props: []Property{ + { + GoName: "ClosedAt", + JsonFieldName: "closed_at", + Schema: GoSchema{GoType: "string"}, + Constraints: Constraints{Required: ptr(true)}, + }, + }, + options: ParseOptions{ + AdditionalTags: []string{"yaml"}, + SkipValidation: true, + }, + contains: []string{`json:"closed_at"`, `yaml:"closed_at"`}, + }, + { + name: "yaml tag includes omitempty when nullable", + props: []Property{ + { + GoName: "LabelName", + JsonFieldName: "label_name", + Schema: GoSchema{GoType: "*string"}, + Constraints: Constraints{Nullable: ptr(true)}, + }, + }, + options: ParseOptions{ + AdditionalTags: []string{"yaml"}, + SkipValidation: true, + }, + contains: []string{`json:"label_name,omitempty"`, `yaml:"label_name,omitempty"`}, + }, + { + name: "extra-tags take priority over additional-tags", + props: []Property{ + { + GoName: "ExtraOverride", + JsonFieldName: "extra_override", + Schema: GoSchema{GoType: "*string"}, + Constraints: Constraints{Nullable: ptr(true)}, + Extensions: map[string]any{ + "x-oapi-codegen-extra-tags": map[string]any{ + "yaml": "custom_override", + }, + }, + }, + }, + options: ParseOptions{ + AdditionalTags: []string{"yaml"}, + SkipValidation: true, + }, + contains: []string{`json:"extra_override,omitempty"`, `yaml:"custom_override"`}, + notContains: []string{`yaml:"extra_override,omitempty"`}, + }, + { + name: "json ignored fields get yaml ignored too", + props: []Property{ + { + GoName: "IgnoredField", + JsonFieldName: "ignored_field", + Schema: GoSchema{GoType: "*string"}, + Extensions: map[string]any{ + "x-go-json-ignore": true, + }, + }, + }, + options: ParseOptions{ + AdditionalTags: []string{"yaml"}, + SkipValidation: true, + }, + contains: []string{`json:"-"`, `yaml:"-"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := genFieldsFromProperties(tt.props, tt.options) + assert.Len(t, fields, 1) + for _, s := range tt.contains { + assert.Contains(t, fields[0], s) + } + for _, s := range tt.notContains { + assert.NotContains(t, fields[0], s) + } + }) + } +} + func TestProperty_GoTypeDef_nullable(t *testing.T) { type fields struct { GlobalStateDisableRequiredReadOnlyAsPointer bool