Skip to content

Commit e56b2a1

Browse files
authored
openapi3gen: Add an option to customize generated field names (properties) for structs (getkin#1204)
1 parent 6c01290 commit e56b2a1

3 files changed

Lines changed: 189 additions & 19 deletions

File tree

.github/docs/openapi3gen.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ type ExportComponentSchemasOptions struct {
3333
ExportGenerics bool
3434
}
3535

36+
type FieldNameGenerator func(field reflect.StructField, defaultName string) string
37+
FieldNameGenerator allows client to set custom name for struct fields in
38+
the generated schema. defaultName is the name, determined by generator's
39+
standard JSON, YAML and Go field name resolution rules. Useful for
40+
processing custom binding tags, such as `form` or `xml`. Function should
41+
always return non-empty string.
42+
3643
type Generator struct {
3744
Types map[reflect.Type]*openapi3.SchemaRef
3845

@@ -60,6 +67,8 @@ func CreateComponentSchemas(exso ExportComponentSchemasOptions) Option
6067
CreateComponents changes the default behavior to add all schemas as
6168
components Reduces duplicate schemas in routes
6269

70+
func CreateFieldNameGenerator(fngnrt FieldNameGenerator) Option
71+
6372
func CreateTypeNameGenerator(tngnrt TypeNameGenerator) Option
6473

6574
func SchemaCustomizer(sc SchemaCustomizerFn) Option

openapi3gen/openapi3gen.go

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,19 @@ type ExportComponentSchemasOptions struct {
5252

5353
type TypeNameGenerator func(t reflect.Type) string
5454

55+
// FieldNameGenerator allows client to set custom name for struct fields in the generated schema.
56+
// defaultName is the name, determined by generator's standard JSON, YAML and Go field name resolution rules.
57+
// Useful for processing custom binding tags, such as `form` or `xml`.
58+
// Function should always return non-empty string.
59+
type FieldNameGenerator func(field reflect.StructField, defaultName string) string
60+
5561
type generatorOpt struct {
5662
useAllExportedFields bool
5763
throwErrorOnCycle bool
5864
schemaCustomizer SchemaCustomizerFn
5965
exportComponentSchemas ExportComponentSchemasOptions
6066
typeNameGenerator TypeNameGenerator
67+
fieldNameGenerator FieldNameGenerator
6168
}
6269

6370
// UseAllExportedFields changes the default behavior of only
@@ -70,6 +77,10 @@ func CreateTypeNameGenerator(tngnrt TypeNameGenerator) Option {
7077
return func(x *generatorOpt) { x.typeNameGenerator = tngnrt }
7178
}
7279

80+
func CreateFieldNameGenerator(fngnrt FieldNameGenerator) Option {
81+
return func(x *generatorOpt) { x.fieldNameGenerator = fngnrt }
82+
}
83+
7384
// ThrowErrorOnCycle changes the default behavior of creating cycle
7485
// refs to instead error if a cycle is detected.
7586
func ThrowErrorOnCycle() Option {
@@ -342,28 +353,13 @@ func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type
342353
if !fieldInfo.HasJSONTag && !g.opts.useAllExportedFields {
343354
continue
344355
}
356+
345357
// If asked, try to use yaml tag
346358
fieldName, fType := fieldInfo.JSONName, fieldInfo.Type
359+
ff := getStructField(t, fieldInfo)
347360
if !fieldInfo.HasJSONTag && g.opts.useAllExportedFields {
348-
// Handle anonymous fields/embedded structs
349-
if t.Field(fieldInfo.Index[0]).Anonymous {
350-
ref, err := g.generateSchemaRefFor(parents, fType, fieldName, tag)
351-
if err != nil {
352-
if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle {
353-
ref = g.generateCycleSchemaRef(fType, schema)
354-
} else {
355-
return nil, err
356-
}
357-
}
358-
if ref != nil {
359-
g.SchemaRefs[ref]++
360-
schema.WithPropertyRef(fieldName, ref)
361-
}
362-
} else {
363-
ff := getStructField(t, fieldInfo)
364-
if tag, ok := ff.Tag.Lookup("yaml"); ok && tag != "-" {
365-
fieldName, fType = tag, ff.Type
366-
}
361+
if tag, ok := ff.Tag.Lookup("yaml"); ok && tag != "-" {
362+
fieldName = tag
367363
}
368364
}
369365

@@ -374,6 +370,13 @@ func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type
374370
fieldTag = ff.Tag
375371
}
376372

373+
if g.opts.fieldNameGenerator != nil {
374+
fieldName = g.opts.fieldNameGenerator(ff, fieldName)
375+
if fieldName == "" {
376+
return nil, fmt.Errorf("field name can't be empty")
377+
}
378+
}
379+
377380
ref, err := g.generateSchemaRefFor(parents, fType, fieldName, fieldTag)
378381
if err != nil {
379382
if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle {

openapi3gen/openapi3gen_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"maps"
78
"reflect"
9+
"slices"
810
"strconv"
911
"strings"
1012
"testing"
@@ -704,3 +706,159 @@ func TestExportComponentSchemasSkipsAnonymousType(t *testing.T) {
704706
assert.NotEmpty(t, key, "every component schema must have a non-empty key")
705707
}
706708
}
709+
710+
func TestEmbeddedFieldGeneratedOnce(t *testing.T) {
711+
type Embedded struct {
712+
Field string
713+
}
714+
type Container struct {
715+
Embedded
716+
}
717+
718+
calls := 0
719+
g := openapi3gen.NewGenerator(
720+
openapi3gen.UseAllExportedFields(),
721+
openapi3gen.SchemaCustomizer(func(name string, _ reflect.Type, _ reflect.StructTag, _ *openapi3.Schema) error {
722+
if name == "Field" {
723+
calls++
724+
}
725+
return nil
726+
}),
727+
)
728+
729+
schemaRef, err := g.GenerateSchemaRef(reflect.TypeFor[Container]())
730+
require.NoError(t, err)
731+
require.Contains(t, schemaRef.Value.Properties, "Field")
732+
require.Equal(t, 1, calls)
733+
}
734+
735+
func TestFieldNameGenerator(t *testing.T) {
736+
type Embedded struct {
737+
EmbeddedField string
738+
}
739+
type Container struct {
740+
PlainField string
741+
JSONField string `json:"json_name"`
742+
YAMLField string `yaml:"yaml_name"`
743+
TaggedField string `property:"custom_name"`
744+
Embedded
745+
}
746+
747+
tests := []struct {
748+
name string
749+
generator openapi3gen.FieldNameGenerator
750+
wantFields []string
751+
wantDefaults map[string]string
752+
wantGoFields []string
753+
wantFieldTags map[string]string
754+
wantErr bool
755+
}{
756+
{
757+
name: "customizes untagged fields",
758+
generator: func(_ reflect.StructField, defaultName string) string { return strings.ToLower(defaultName) },
759+
wantFields: []string{
760+
"plainfield",
761+
"json_name",
762+
"yaml_name",
763+
"taggedfield",
764+
"embeddedfield",
765+
},
766+
},
767+
{
768+
name: "customizes explicit json and yaml names",
769+
generator: func(_ reflect.StructField, defaultName string) string { return "prefix_" + defaultName },
770+
wantFields: []string{
771+
"prefix_PlainField",
772+
"prefix_json_name",
773+
"prefix_yaml_name",
774+
"prefix_TaggedField",
775+
"prefix_EmbeddedField",
776+
},
777+
},
778+
{
779+
name: "uses custom struct tag",
780+
generator: func(f reflect.StructField, defaultName string) string {
781+
if name := f.Tag.Get("property"); name != "" {
782+
return name
783+
}
784+
return defaultName
785+
},
786+
wantFields: []string{"PlainField", "json_name", "yaml_name", "custom_name", "EmbeddedField"},
787+
wantFieldTags: map[string]string{"TaggedField": "custom_name"},
788+
},
789+
{
790+
name: "receives promoted embedded field",
791+
generator: func(_ reflect.StructField, defaultName string) string { return defaultName },
792+
wantFields: []string{
793+
"PlainField",
794+
"json_name",
795+
"yaml_name",
796+
"TaggedField",
797+
"EmbeddedField",
798+
},
799+
wantGoFields: []string{"EmbeddedField"},
800+
},
801+
{
802+
name: "receives resolved default names",
803+
generator: func(field reflect.StructField, defaultName string) string { return defaultName },
804+
wantFields: []string{
805+
"PlainField",
806+
"json_name",
807+
"yaml_name",
808+
"TaggedField",
809+
"EmbeddedField",
810+
},
811+
wantDefaults: map[string]string{
812+
"JSONField": "json_name",
813+
"PlainField": "PlainField",
814+
"YAMLField": "yaml_name",
815+
"EmbeddedField": "EmbeddedField",
816+
},
817+
},
818+
{
819+
name: "empty field names are rejected",
820+
generator: func(field reflect.StructField, defaultName string) string { return "" },
821+
wantErr: true,
822+
},
823+
}
824+
825+
for _, tt := range tests {
826+
t.Run(tt.name, func(t *testing.T) {
827+
gotDefaults := make(map[string]string)
828+
gotTags := make(map[string]string)
829+
gotGoFields := make(map[string]bool)
830+
831+
g := openapi3gen.NewGenerator(
832+
openapi3gen.UseAllExportedFields(),
833+
openapi3gen.CreateFieldNameGenerator(func(f reflect.StructField, defaultName string) string {
834+
gotDefaults[f.Name] = defaultName
835+
gotTags[f.Name] = f.Tag.Get("property")
836+
gotGoFields[f.Name] = true
837+
return tt.generator(f, defaultName)
838+
}),
839+
)
840+
841+
schemaRef, err := g.GenerateSchemaRef(reflect.TypeFor[Container]())
842+
if tt.wantErr {
843+
require.Error(t, err)
844+
return
845+
}
846+
847+
require.NoError(t, err)
848+
849+
require.ElementsMatch(t, tt.wantFields, slices.Collect(maps.Keys(schemaRef.Value.Properties)))
850+
851+
for field, want := range tt.wantDefaults {
852+
require.Equal(t, want, gotDefaults[field])
853+
}
854+
855+
for field, want := range tt.wantFieldTags {
856+
require.Equal(t, want, gotTags[field])
857+
}
858+
859+
for _, field := range tt.wantGoFields {
860+
require.True(t, gotGoFields[field], "field name generator was not called for %s", field)
861+
}
862+
})
863+
}
864+
}

0 commit comments

Comments
 (0)