Repository: getkin/kin-openapi
Pull request #935 (2024-04-06 10:40:35)

openapi3gen: add CreateComponentSchemas option to export object schemas to components
(Rebase of) Closes https://github.com/getkin/kin-openapi/pull/914


Patch:
diff --git a/.github/docs/openapi3filter_fixtures.txt b/.github/docs/openapi3filter_fixtures.txt
deleted file mode 100644
index e69de29..0000000
diff --git a/.github/docs/openapi3gen.txt b/.github/docs/openapi3gen.txt
index e692b85..0626e87 100644
--- a/.github/docs/openapi3gen.txt
+++ b/.github/docs/openapi3gen.txt
@@ -27,6 +27,12 @@ type ExcludeSchemaSentinel struct{}
 
 func (err *ExcludeSchemaSentinel) Error() string
 
+type ExportComponentSchemasOptions struct {
+	ExportComponentSchemas bool
+	ExportTopLevelSchema   bool
+	ExportGenerics         bool
+}
+
 type Generator struct {
 	Types map[reflect.Type]*openapi3.SchemaRef
 
@@ -50,6 +56,12 @@ func (g *Generator) NewSchemaRefForValue(value interface{}, schemas openapi3.Sch
 type Option func(*generatorOpt)
     Option allows tweaking SchemaRef generation
 
+func CreateComponentSchemas(exso ExportComponentSchemasOptions) Option
+    CreateComponents changes the default behavior to add all schemas as
+    components Reduces duplicate schemas in routes
+
+func CreateTypeNameGenerator(tngnrt TypeNameGenerator) Option
+
 func SchemaCustomizer(sc SchemaCustomizerFn) Option
     SchemaCustomizer allows customization of the schema that is generated for a
     field, for example to support an additional tagging scheme
@@ -77,3 +89,5 @@ type SetSchemar interface {
     their specification. Useful when some custom datatype is needed and/or some
     custom logic is needed on how the schema values would be generated
 
+type TypeNameGenerator func(t reflect.Type) string
+
diff --git a/docs.sh b/docs.sh
index 08d3eb0..ca3029a 100755
--- a/docs.sh
+++ b/docs.sh
@@ -3,7 +3,8 @@ set -o pipefail
 
 outdir=.github/docs
 mkdir -p "$outdir"
-for pkgpath in $(git ls-files | grep  / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|testdata|cmd/'); do
+for pkgpath in $(git ls-files | grep  / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|testdata|internal|cmd/'); do
+	echo $pkgpath
 	go doc -all ./"$pkgpath" | tee "$outdir/${pkgpath////_}.txt"
 done
 
diff --git a/openapi3gen/internal/subpkg/sub_type.go b/openapi3gen/internal/subpkg/sub_type.go
new file mode 100644
index 0000000..3dc961d
--- /dev/null
+++ b/openapi3gen/internal/subpkg/sub_type.go
@@ -0,0 +1,5 @@
+package subpkg
+
+type Child struct {
+	Name string `yaml:"name"`
+}
diff --git a/openapi3gen/openapi3gen.go b/openapi3gen/openapi3gen.go
index a301337..6afed00 100644
--- a/openapi3gen/openapi3gen.go
+++ b/openapi3gen/openapi3gen.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"math"
 	"reflect"
+	"regexp"
 	"strings"
 	"time"
 
@@ -42,10 +43,20 @@ type SetSchemar interface {
 	SetSchema(*openapi3.Schema)
 }
 
+type ExportComponentSchemasOptions struct {
+	ExportComponentSchemas bool
+	ExportTopLevelSchema   bool
+	ExportGenerics         bool
+}
+
+type TypeNameGenerator func(t reflect.Type) string
+
 type generatorOpt struct {
-	useAllExportedFields bool
-	throwErrorOnCycle    bool
-	schemaCustomizer     SchemaCustomizerFn
+	useAllExportedFields   bool
+	throwErrorOnCycle      bool
+	schemaCustomizer       SchemaCustomizerFn
+	exportComponentSchemas ExportComponentSchemasOptions
+	typeNameGenerator      TypeNameGenerator
 }
 
 // UseAllExportedFields changes the default behavior of only
@@ -54,6 +65,10 @@ func UseAllExportedFields() Option {
 	return func(x *generatorOpt) { x.useAllExportedFields = true }
 }
 
+func CreateTypeNameGenerator(tngnrt TypeNameGenerator) Option {
+	return func(x *generatorOpt) { x.typeNameGenerator = tngnrt }
+}
+
 // ThrowErrorOnCycle changes the default behavior of creating cycle
 // refs to instead error if a cycle is detected.
 func ThrowErrorOnCycle() Option {
@@ -66,6 +81,13 @@ func SchemaCustomizer(sc SchemaCustomizerFn) Option {
 	return func(x *generatorOpt) { x.schemaCustomizer = sc }
 }
 
+// CreateComponents changes the default behavior
+// to add all schemas as components
+// Reduces duplicate schemas in routes
+func CreateComponentSchemas(exso ExportComponentSchemasOptions) Option {
+	return func(x *generatorOpt) { x.exportComponentSchemas = exso }
+}
+
 // NewSchemaRefForValue is a shortcut for NewGenerator(...).NewSchemaRefForValue(...)
 func NewSchemaRefForValue(value interface{}, schemas openapi3.Schemas, opts ...Option) (*openapi3.SchemaRef, error) {
 	g := NewGenerator(opts...)
@@ -83,6 +105,7 @@ type Generator struct {
 	SchemaRefs map[*openapi3.SchemaRef]int
 
 	// componentSchemaRefs is a set of schemas that must be defined in the components to avoid cycles
+	// or if we have specified create components schemas
 	componentSchemaRefs map[string]struct{}
 }
 
@@ -111,9 +134,16 @@ func (g *Generator) NewSchemaRefForValue(value interface{}, schemas openapi3.Sch
 		return nil, err
 	}
 	for ref := range g.SchemaRefs {
-		if _, ok := g.componentSchemaRefs[ref.Ref]; ok && schemas != nil {
-			schemas[ref.Ref] = &openapi3.SchemaRef{
-				Value: ref.Value,
+		refName := ref.Ref
+		if g.opts.exportComponentSchemas.ExportComponentSchemas && strings.HasPrefix(refName, "#/components/schemas/") {
+			refName = strings.TrimPrefix(refName, "#/components/schemas/")
+		}
+
+		if _, ok := g.componentSchemaRefs[refName]; ok && schemas != nil {
+			if ref.Value != nil && ref.Value.Properties != nil {
+				schemas[refName] = &openapi3.SchemaRef{
+					Value: ref.Value,
+				}
 			}
 		}
 		if strings.HasPrefix(ref.Ref, "#/components/schemas/") {
@@ -298,6 +328,14 @@ func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type
 			schema.Type = &openapi3.Types{"string"}
 			schema.Format = "date-time"
 		} else {
+			typeName := g.generateTypeName(t)
+
+			if _, ok := g.componentSchemaRefs[typeName]; ok && g.opts.exportComponentSchemas.ExportComponentSchemas {
+				// Check if we have already parsed this component schema ref based on the name of the struct
+				// and use that if so
+				return openapi3.NewSchemaRef(fmt.Sprintf("#/components/schemas/%s", typeName), schema), nil
+			}
+
 			for _, fieldInfo := range typeInfo.Fields {
 				// Only fields with JSON tag are considered (by default)
 				if !fieldInfo.HasJSONTag && !g.opts.useAllExportedFields {
@@ -347,6 +385,7 @@ func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type
 					g.SchemaRefs[ref]++
 					schema.WithPropertyRef(fieldName, ref)
 				}
+
 			}
 
 			// Object only if it has properties
@@ -362,6 +401,7 @@ func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type
 				v.SetSchema(schema)
 			}
 		}
+
 	}
 
 	if g.opts.schemaCustomizer != nil {
@@ -370,9 +410,40 @@ func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type
 		}
 	}
 
+	if !g.opts.exportComponentSchemas.ExportComponentSchemas || t.Kind() != reflect.Struct {
+		return openapi3.NewSchemaRef(t.Name(), schema), nil
+	}
+
+	// Best way I could find to check that
+	// this current type is a generic
+	isGeneric, err := regexp.Match(`^.*\[.*\]$`, []byte(t.Name()))
+	if err != nil {
+		return nil, err
+	}
+
+	if isGeneric && !g.opts.exportComponentSchemas.ExportGenerics {
+		return openapi3.NewSchemaRef(t.Name(), schema), nil
+	}
+
+	// For structs we add the schemas to the component schemas
+	if len(parents) > 1 || g.opts.exportComponentSchemas.ExportTopLevelSchema {
+		typeName := g.generateTypeName(t)
+
+		g.componentSchemaRefs[typeName] = struct{}{}
+		return openapi3.NewSchemaRef(fmt.Sprintf("#/components/schemas/%s", typeName), schema), nil
+	}
+
 	return openapi3.NewSchemaRef(t.Name(), schema), nil
 }
 
+func (g *Generator) generateTypeName(t reflect.Type) string {
+	if g.opts.typeNameGenerator != nil {
+		return g.opts.typeNameGenerator(t)
+	}
+
+	return t.Name()
+}
+
 func (g *Generator) generateCycleSchemaRef(t reflect.Type, schema *openapi3.Schema) *openapi3.SchemaRef {
 	var typeName string
 	switch t.Kind() {
@@ -391,7 +462,7 @@ func (g *Generator) generateCycleSchemaRef(t reflect.Type, schema *openapi3.Sche
 		mapSchema.AdditionalProperties = openapi3.AdditionalProperties{Schema: ref}
 		return openapi3.NewSchemaRef("", mapSchema)
 	default:
-		typeName = t.Name()
+		typeName = g.generateTypeName(t)
 	}
 
 	g.componentSchemaRefs[typeName] = struct{}{}


Test patch:
diff --git a/openapi3gen/openapi3gen_newschemarefforvalue_test.go b/openapi3gen/openapi3gen_newschemarefforvalue_test.go
new file mode 100644
index 0000000..1c46f23
--- /dev/null
+++ b/openapi3gen/openapi3gen_newschemarefforvalue_test.go
@@ -0,0 +1,429 @@
+package openapi3gen_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/openapi3gen"
+	"github.com/getkin/kin-openapi/openapi3gen/internal/subpkg"
+)
+
+// Make sure that custom schema name generator is employed and results produced with it are properly used
+func ExampleNewSchemaRefForValue_withSubPackages() {
+	type Parent struct {
+		Field1 string       `json:"field1"`
+		Child  subpkg.Child `json:"child"`
+	}
+
+	// these schema names should be returned by name generator
+	parentSchemaName := "PARENT_TYPE"
+	childSchemaName := "CHILD_TYPE"
+
+	// sample of a type name generator
+	typeNameGenerator := func(t reflect.Type) string {
+		switch t {
+		case reflect.TypeOf(Parent{}):
+			return parentSchemaName
+		case reflect.TypeOf(subpkg.Child{}):
+			return childSchemaName
+		}
+		return t.Name()
+	}
+
+	schemas := make(openapi3.Schemas)
+	schemaRef, err := openapi3gen.NewSchemaRefForValue(
+		&Parent{},
+		schemas,
+		openapi3gen.CreateComponentSchemas(openapi3gen.ExportComponentSchemasOptions{
+			ExportComponentSchemas: true, ExportTopLevelSchema: true,
+		}),
+		openapi3gen.CreateTypeNameGenerator(typeNameGenerator),
+		openapi3gen.UseAllExportedFields(),
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	var data []byte
+	if data, err = json.MarshalIndent(&schemas, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemas: %s\n", data)
+	if data, err = json.MarshalIndent(&schemaRef, "", "  "); err != nil {
+		panic(err)
+	}
+
+	fmt.Printf("schemaRef: %s\n", data)
+	// Output:
+	// schemas: {
+	//   "CHILD_TYPE": {
+	//     "properties": {
+	//       "name": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   },
+	//   "PARENT_TYPE": {
+	//     "properties": {
+	//       "child": {
+	//         "$ref": "#/components/schemas/CHILD_TYPE"
+	//       },
+	//       "field1": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   }
+	// }
+	// schemaRef: {
+	//   "$ref": "#/components/schemas/PARENT_TYPE"
+	// }
+
+}
+
+func ExampleNewSchemaRefForValue_withExportingSchemas() {
+	type Child struct {
+		Age string `json:"age"`
+	}
+	type AnotherStruct struct {
+		Field1 string `json:"field1"`
+		Field2 string `json:"field2"`
+		Field3 string `json:"field3"`
+	}
+	type RecursiveType struct {
+		Field1        string        `json:"field1"`
+		Field2        string        `json:"field2"`
+		Field3        string        `json:"field3"`
+		AnotherStruct AnotherStruct `json:"children,omitempty"`
+		Child         subpkg.Child  `json:"child"`
+		Child2        Child         `json:"child2"`
+	}
+
+	// sample of a type name generator
+	typeNameGenerator := func(t reflect.Type) string {
+		packages := strings.Split(t.PkgPath(), "/")
+		return packages[len(packages)-1] + "_" + t.Name()
+	}
+
+	schemas := make(openapi3.Schemas)
+	schemaRef, err := openapi3gen.NewSchemaRefForValue(
+		&RecursiveType{},
+		schemas,
+		openapi3gen.CreateComponentSchemas(openapi3gen.ExportComponentSchemasOptions{
+			ExportComponentSchemas: true, ExportTopLevelSchema: false,
+		}),
+		openapi3gen.CreateTypeNameGenerator(typeNameGenerator),
+		openapi3gen.UseAllExportedFields(),
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	var schemasByte []byte
+	if schemasByte, err = json.MarshalIndent(&schemas, "", "  "); err != nil {
+		panic(err)
+	}
+	var schemaRefByte []byte
+	if schemaRefByte, err = json.MarshalIndent(&schemaRef, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemas: %s\nschemaRef: %s\n", schemasByte, schemaRefByte)
+	// Output:
+	// schemas: {
+	//   "openapi3gen_test_AnotherStruct": {
+	//     "properties": {
+	//       "field1": {
+	//         "type": "string"
+	//       },
+	//       "field2": {
+	//         "type": "string"
+	//       },
+	//       "field3": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   },
+	//   "openapi3gen_test_Child": {
+	//     "properties": {
+	//       "age": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   },
+	//   "subpkg_Child": {
+	//     "properties": {
+	//       "name": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   }
+	// }
+	// schemaRef: {
+	//   "properties": {
+	//     "child": {
+	//       "$ref": "#/components/schemas/subpkg_Child"
+	//     },
+	//     "child2": {
+	//       "$ref": "#/components/schemas/openapi3gen_test_Child"
+	//     },
+	//     "children": {
+	//       "$ref": "#/components/schemas/openapi3gen_test_AnotherStruct"
+	//     },
+	//     "field1": {
+	//       "type": "string"
+	//     },
+	//     "field2": {
+	//       "type": "string"
+	//     },
+	//     "field3": {
+	//       "type": "string"
+	//     }
+	//   },
+	//   "type": "object"
+	// }
+}
+
+func ExampleNewSchemaRefForValue_withExportingSchemasIgnoreTopLevelParent() {
+	type AnotherStruct struct {
+		Field1 string `json:"field1"`
+		Field2 string `json:"field2"`
+		Field3 string `json:"field3"`
+	}
+	type RecursiveType struct {
+		Field1        string        `json:"field1"`
+		Field2        string        `json:"field2"`
+		Field3        string        `json:"field3"`
+		AnotherStruct AnotherStruct `json:"children,omitempty"`
+	}
+
+	schemas := make(openapi3.Schemas)
+	schemaRef, err := openapi3gen.NewSchemaRefForValue(&RecursiveType{}, schemas, openapi3gen.CreateComponentSchemas(openapi3gen.ExportComponentSchemasOptions{
+		ExportComponentSchemas: true, ExportTopLevelSchema: false,
+	}))
+	if err != nil {
+		panic(err)
+	}
+
+	var data []byte
+	if data, err = json.MarshalIndent(&schemas, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemas: %s\n", data)
+	if data, err = json.MarshalIndent(&schemaRef, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemaRef: %s\n", data)
+	// Output:
+	// schemas: {
+	//   "AnotherStruct": {
+	//     "properties": {
+	//       "field1": {
+	//         "type": "string"
+	//       },
+	//       "field2": {
+	//         "type": "string"
+	//       },
+	//       "field3": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   }
+	// }
+	// schemaRef: {
+	//   "properties": {
+	//     "children": {
+	//       "$ref": "#/components/schemas/AnotherStruct"
+	//     },
+	//     "field1": {
+	//       "type": "string"
+	//     },
+	//     "field2": {
+	//       "type": "string"
+	//     },
+	//     "field3": {
+	//       "type": "string"
+	//     }
+	//   },
+	//   "type": "object"
+	// }
+}
+
+func ExampleNewSchemaRefForValue_withExportingSchemasWithGeneric() {
+	type Child struct {
+		Age string `json:"age"`
+	}
+	type GenericStruct[T any] struct {
+		GenericField T     `json:"genericField"`
+		Child        Child `json:"child"`
+	}
+	type AnotherStruct struct {
+		Field1 string `json:"field1"`
+		Field2 string `json:"field2"`
+		Field3 string `json:"field3"`
+	}
+	type RecursiveType struct {
+		Field1        string                `json:"field1"`
+		Field2        string                `json:"field2"`
+		Field3        string                `json:"field3"`
+		AnotherStruct AnotherStruct         `json:"children,omitempty"`
+		Child         Child                 `json:"child"`
+		GenericStruct GenericStruct[string] `json:"genericChild"`
+	}
+
+	schemas := make(openapi3.Schemas)
+	schemaRef, err := openapi3gen.NewSchemaRefForValue(
+		&RecursiveType{},
+		schemas,
+		openapi3gen.CreateComponentSchemas(openapi3gen.ExportComponentSchemasOptions{
+			ExportComponentSchemas: true, ExportTopLevelSchema: true, ExportGenerics: false,
+		}),
+		openapi3gen.UseAllExportedFields(),
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	var data []byte
+	if data, err = json.MarshalIndent(&schemas, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemas: %s\n", data)
+	if data, err = json.MarshalIndent(&schemaRef, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemaRef: %s\n", data)
+	// Output:
+	// schemas: {
+	//   "AnotherStruct": {
+	//     "properties": {
+	//       "field1": {
+	//         "type": "string"
+	//       },
+	//       "field2": {
+	//         "type": "string"
+	//       },
+	//       "field3": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   },
+	//   "Child": {
+	//     "properties": {
+	//       "age": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   },
+	//   "RecursiveType": {
+	//     "properties": {
+	//       "child": {
+	//         "$ref": "#/components/schemas/Child"
+	//       },
+	//       "children": {
+	//         "$ref": "#/components/schemas/AnotherStruct"
+	//       },
+	//       "field1": {
+	//         "type": "string"
+	//       },
+	//       "field2": {
+	//         "type": "string"
+	//       },
+	//       "field3": {
+	//         "type": "string"
+	//       },
+	//       "genericChild": {
+	//         "properties": {
+	//           "child": {
+	//             "$ref": "#/components/schemas/Child"
+	//           },
+	//           "genericField": {
+	//             "type": "string"
+	//           }
+	//         },
+	//         "type": "object"
+	//       }
+	//     },
+	//     "type": "object"
+	//   }
+	// }
+	// schemaRef: {
+	//   "$ref": "#/components/schemas/RecursiveType"
+	// }
+}
+
+func ExampleNewSchemaRefForValue_withExportingSchemasWithMap() {
+	type Child struct {
+		Age string `json:"age"`
+	}
+	type MyType struct {
+		Field1 string                 `json:"field1"`
+		Field2 string                 `json:"field2"`
+		Map1   map[string]interface{} `json:"anymap"`
+		Map2   map[string]Child       `json:"anymapChild"`
+	}
+
+	schemas := make(openapi3.Schemas)
+	schemaRef, err := openapi3gen.NewSchemaRefForValue(
+		&MyType{},
+		schemas,
+		openapi3gen.CreateComponentSchemas(openapi3gen.ExportComponentSchemasOptions{
+			ExportComponentSchemas: true, ExportTopLevelSchema: false, ExportGenerics: true,
+		}),
+		openapi3gen.UseAllExportedFields(),
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	var data []byte
+	if data, err = json.MarshalIndent(&schemas, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemas: %s\n", data)
+	if data, err = json.MarshalIndent(&schemaRef, "", "  "); err != nil {
+		panic(err)
+	}
+	fmt.Printf("schemaRef: %s\n", data)
+	// Output:
+	// schemas: {
+	//   "Child": {
+	//     "properties": {
+	//       "age": {
+	//         "type": "string"
+	//       }
+	//     },
+	//     "type": "object"
+	//   }
+	// }
+	// schemaRef: {
+	//   "properties": {
+	//     "anymap": {
+	//       "additionalProperties": {},
+	//       "type": "object"
+	//     },
+	//     "anymapChild": {
+	//       "additionalProperties": {
+	//         "$ref": "#/components/schemas/Child"
+	//       },
+	//       "type": "object"
+	//     },
+	//     "field1": {
+	//       "type": "string"
+	//     },
+	//     "field2": {
+	//       "type": "string"
+	//     }
+	//   },
+	//   "type": "object"
+	// }
+}