Repository: go-fuego/fuego
Pull request #510: Added support for openapi3gen schema customizer functions.
Author: login: A-Stone-Olguin
Created: 2025-05-02T14:51:59Z | Merged: 2025-05-22T22:00:33Z

Resolves #508 

Linked issue #508: Support SchemaCustomizerFn Option for Kin-openapi3gen
**Is your feature request related to a problem? Please describe.**
I found that kin-openapi supports this for custom struct parsers: [In their source code](https://github.com/getkin/kin-openapi/blob/master/openapi3gen/openapi3gen.go#L37)

It seems like the parseStructTags function is re-doing this work that kin-openapi supports.
Instead, it seems that we could re-implement the logic of parseStructTags to be as a SchemaCustomizerFn while also allowing users to define their own custom functions to parse struct tags.

Using kin-openapi's functionality to parse structs seems that it would be better for keeping in-sync with kin-openapi functionality.

**Describe the solution you'd like**
Adding support to change the options of the OpenAPI Generator with a config option would do this. Something along the lines of:

In `engine.go`
```
// WithOpenAPIGeneratorConfig sets the options for the generator.
func WithOpenAPIGeneratorSchemaCustomizer(sc openapi3gen.SchemaCustomizerFn) func(*Engine) {
	return func(e *Engine) {
		e.OpenAPI.SetGeneratorSchemaCustomizer(sc)
	}
}
```

In `openapi.go`
```
// Sets the openapi generator with a custom schema customizer function.
func (openAPI *OpenAPI) SetGeneratorSchemaCustomizer(sc openapi3gen.SchemaCustomizerFn) {
	// Create a function with the default schema customizer, and one with the provided, thereby merging the two.
	customizerFn := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
		if err := SchemaCustomizer(name, t, tag, schema); err != nil {
			return err
		}
		if err := sc(name, t, tag, schema); err != nil {
			return err
		}
		return nil
	}
	openAPI.generator = openapi3gen.NewGenerator(openapi3gen.SchemaCustomizer(customizerFn))
}
```
where `SchemaCustomizer` is the logic of `parseStructTags` expressed as a openapi3gen.SchemaCustomizerFn.

**Describe alternatives you've considered**
I believe that what we have for parseStructTags works in its current form.
However, because fuego uses kin-openapi to generate openAPI schema, I think that using kin-openapi's schema customizer would allow fuego users (and developers) to have an easier way of integrating in other customizations, allowing more flexibility.

I considered adding support for all Generator options for kin-openapi, but it seems that enabling generation of reference schema has a bug currently [as listed here](https://github.com/getkin/kin-openapi/issues/1039).

**Additional context**
This feature request seems to be similar to the work what was done in Pull Request #328, and was mentioned to be a nice feature in [this comment](https://github.com/go-fuego/fuego/pull/328#issuecomment-2602398239)

However, this PR is over three months old, and has been suggested to be split up into smaller parts.

Linked issue comments: user: login: A-Stone-Olguin
created_at: 2025-05-02T05:56:58Z
body: Here is my fork which has a sample implementation in a unit test, and shows a practical use in the pet.go example. [Here is the diff link](https://github.com/go-fuego/fuego/compare/main...A-Stone-Olguin:fuego-generator-options:main)

If this feature is considered useful, I’d be happy to open a Pull Request. For now, I’ll keep it in a fork until it’s determined whether this is an appropriate feature to include.
author_association: NONE
user: login: dylanhitt
created_at: 2025-05-02T13:35:28Z
body: Yes initial look, looks really great!

Please open an MR. 

Thank you
author_association: COLLABORATOR

Diff:
diff --git a/engine.go b/engine.go
index 5545c188..c4bdff1c 100644
--- a/engine.go
+++ b/engine.go
@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 
 	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/openapi3gen"
 )
 
 // NewEngine creates a new Engine with the given options.
@@ -138,6 +139,13 @@ func WithOpenAPIConfig(config OpenAPIConfig) func(*Engine) {
 	}
 }
 
+// WithOpenAPIGeneratorConfig sets the options for the generator.
+func WithOpenAPIGeneratorSchemaCustomizer(sc openapi3gen.SchemaCustomizerFn) func(*Engine) {
+	return func(e *Engine) {
+		e.OpenAPI.SetGeneratorSchemaCustomizer(sc)
+	}
+}
+
 // WithErrorHandler sets a customer error handler for the server
 func WithErrorHandler(errorHandler func(ctx context.Context, err error) error) func(*Engine) {
 	return func(e *Engine) {
diff --git a/examples/petstore/lib/server.go b/examples/petstore/lib/server.go
index 4326afc3..6fe63886 100644
--- a/examples/petstore/lib/server.go
+++ b/examples/petstore/lib/server.go
@@ -2,7 +2,11 @@ package lib
 
 import (
 	"net/http"
+	"reflect"
+	"strings"
 
+	"github.com/getkin/kin-openapi/openapi3"
+	"github.com/getkin/kin-openapi/openapi3gen"
 	"github.com/go-fuego/fuego"
 	controller "github.com/go-fuego/fuego/examples/petstore/controllers"
 	"github.com/go-fuego/fuego/examples/petstore/services"
@@ -13,16 +17,36 @@ type NoContent struct {
 	Empty string `json:"-"`
 }
 
+var uuidParser openapi3gen.SchemaCustomizerFn = func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+	validateTag := tag.Get("validate")
+	if validateTag == "" {
+		return nil
+	}
+
+	parts := strings.Split(validateTag, ",")
+	for _, part := range parts {
+		if part == "uuid" {
+			schema.Format = "uuid"
+		}
+	}
+	return nil
+}
+
 func NewPetStoreServer(options ...func(*fuego.Server)) *fuego.Server {
 	options = append(options, fuego.WithRouteOptions(
 		option.AddResponse(http.StatusNoContent, "No Content", fuego.Response{Type: NoContent{}}),
 	))
+	options = append(options, fuego.WithEngineOptions(
+		fuego.WithOpenAPIGeneratorSchemaCustomizer(
+			uuidParser,
+		),
+	))
+
 	s := fuego.NewServer(options...)
 
 	petsResources := controller.PetsResources{
 		PetsService: services.NewInMemoryPetsService(), // Dependency injection: we can pass a service here (for example a database service)
 	}
 	petsResources.Routes(s)
-
 	return s
 }
diff --git a/examples/petstore/lib/testdata/doc/openapi.golden.json b/examples/petstore/lib/testdata/doc/openapi.golden.json
index d2c221c1..6aafbbeb 100644
--- a/examples/petstore/lib/testdata/doc/openapi.golden.json
+++ b/examples/petstore/lib/testdata/doc/openapi.golden.json
@@ -42,6 +42,34 @@
 									}
 								},
 								"type": "object"
+							},
+							"treats": {
+								"items": {
+									"nullable": true,
+									"properties": {
+										"brand": {
+											"description": "The brand of the treat",
+											"nullable": true,
+											"type": "string"
+										},
+										"itemId": {
+											"description": "The unique id of the treat",
+											"format": "uuid",
+											"type": "string"
+										},
+										"name": {
+											"description": "the name of a treat",
+											"type": "string"
+										}
+									},
+									"required": [
+										"itemId",
+										"name"
+									],
+									"type": "object"
+								},
+								"nullable": true,
+								"type": "array"
 							}
 						},
 						"required": [
@@ -94,6 +122,34 @@
 									}
 								},
 								"type": "object"
+							},
+							"treats": {
+								"items": {
+									"nullable": true,
+									"properties": {
+										"brand": {
+											"description": "The brand of the treat",
+											"nullable": true,
+											"type": "string"
+										},
+										"itemId": {
+											"description": "The unique id of the treat",
+											"format": "uuid",
+											"type": "string"
+										},
+										"name": {
+											"description": "the name of a treat",
+											"type": "string"
+										}
+									},
+									"required": [
+										"itemId",
+										"name"
+									],
+									"type": "object"
+								},
+								"nullable": true,
+								"type": "array"
 							}
 						},
 						"required": [
@@ -118,9 +174,13 @@
 					},
 					"errors": {
 						"items": {
+							"nullable": true,
 							"properties": {
 								"more": {
-									"additionalProperties": {},
+									"additionalProperties": {
+										"description": "Additional information about the error",
+										"nullable": true
+									},
 									"description": "Additional information about the error",
 									"nullable": true,
 									"type": "object"
@@ -201,6 +261,34 @@
 							}
 						},
 						"type": "object"
+					},
+					"treats": {
+						"items": {
+							"nullable": true,
+							"properties": {
+								"brand": {
+									"description": "The brand of the treat",
+									"nullable": true,
+									"type": "string"
+								},
+								"itemId": {
+									"description": "The unique id of the treat",
+									"format": "uuid",
+									"type": "string"
+								},
+								"name": {
+									"description": "the name of a treat",
+									"type": "string"
+								}
+							},
+							"required": [
+								"itemId",
+								"name"
+							],
+							"type": "object"
+						},
+						"nullable": true,
+						"type": "array"
 					}
 				},
 				"required": [
@@ -291,6 +379,34 @@
 							}
 						},
 						"type": "object"
+					},
+					"treats": {
+						"items": {
+							"nullable": true,
+							"properties": {
+								"brand": {
+									"description": "The brand of the treat",
+									"nullable": true,
+									"type": "string"
+								},
+								"itemId": {
+									"description": "The unique id of the treat",
+									"format": "uuid",
+									"type": "string"
+								},
+								"name": {
+									"description": "the name of a treat",
+									"type": "string"
+								}
+							},
+							"required": [
+								"itemId",
+								"name"
+							],
+							"type": "object"
+						},
+						"nullable": true,
+						"type": "array"
 					}
 				},
 				"type": "object"
diff --git a/examples/petstore/models/Pet.go b/examples/petstore/models/Pet.go
index 355df87a..746ab36d 100644
--- a/examples/petstore/models/Pet.go
+++ b/examples/petstore/models/Pet.go
@@ -22,6 +22,7 @@ type Pets struct {
 	IsAdopted  bool       `json:"is_adopted" description:"Is the pet adopted"`
 	References References `json:"references"`
 	BirthDate  time.Time  `json:"birth_date"`
+	FavTreats  []Treat    `json:"treats,omitempty" validate:"dive"`
 }
 
 type PetsCreate struct {
@@ -36,6 +37,7 @@ type PetsUpdate struct {
 	Age        int        `json:"age,omitempty" validate:"max=100" example:"2"`
 	IsAdopted  *bool      `json:"is_adopted,omitempty" description:"Is the pet adopted"`
 	References References `json:"references"`
+	FavTreats  []Treat    `json:"treats,omitempty"`
 }
 
 type References struct {
@@ -43,6 +45,12 @@ type References struct {
 	Value string `json:"value"`
 }
 
+type Treat struct {
+	Name   string `json:"name" validate:"required" description:"the name of a treat"`
+	Brand  string `json:"brand,omitempty" description:"The brand of the treat"`
+	ItemID string `json:"itemId" validate:"required,uuid" description:"The unique id of the treat"`
+}
+
 var _ fuego.InTransformer = &Pets{}
 
 func (*Pets) InTransform(context.Context) error {
diff --git a/openapi.go b/openapi.go
index dd3f05f5..585853ea 100644
--- a/openapi.go
+++ b/openapi.go
@@ -18,7 +18,7 @@ func NewOpenAPI() *OpenAPI {
 	desc := NewOpenApiSpec()
 	return &OpenAPI{
 		description:            &desc,
-		generator:              openapi3gen.NewGenerator(),
+		generator:              openapi3gen.NewGenerator(openapi3gen.SchemaCustomizer(SchemaCustomizer)),
 		globalOpenAPIResponses: []openAPIResponse{},
 		Config:                 defaultOpenAPIConfig,
 	}
@@ -40,6 +40,18 @@ func (openAPI *OpenAPI) Generator() *openapi3gen.Generator {
 	return openAPI.generator
 }
 
+// Sets the openapi generator with a custom schema customizer function.
+func (openAPI *OpenAPI) SetGeneratorSchemaCustomizer(sc openapi3gen.SchemaCustomizerFn) {
+	// Create a function with the default schema customizer, and one with the provided, thereby merging the two.
+	customizerFn := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+		if err := SchemaCustomizer(name, t, tag, schema); err != nil {
+			return err
+		}
+		return sc(name, t, tag, schema)
+	}
+	openAPI.generator = openapi3gen.NewGenerator(openapi3gen.SchemaCustomizer(customizerFn))
+}
+
 // Compute the tags to declare at the root of the OpenAPI spec from the tags declared in the operations.
 func (openAPI *OpenAPI) computeTags() {
 	for _, pathItem := range openAPI.Description().Paths.Map() {
@@ -383,156 +395,11 @@ func (openAPI *OpenAPI) createSchema(key string, v any) *openapi3.SchemaRef {
 		schemaRef.Value.Description = descriptionable.Description()
 	}
 
-	parseStructTags(reflect.TypeOf(v), schemaRef)
-
 	openAPI.Description().Components.Schemas[key] = schemaRef
 
 	return schemaRef
 }
 
-// parseStructTags parses struct tags and modifies the schema accordingly.
-// t must be a struct type.
-// It adds the following struct tags (tag => OpenAPI schema field):
-// - description => description
-// - example => example
-// - json => nullable (if contains omitempty)
-// - validate:
-//   - required => required
-//   - min=1 => min=1 (for integers)
-//   - min=1 => minLength=1 (for strings)
-//   - max=100 => max=100 (for integers)
-//   - max=100 => maxLength=100 (for strings)
-func parseStructTags(t reflect.Type, schemaRef *openapi3.SchemaRef) {
-	if t.Kind() == reflect.Ptr {
-		t = t.Elem()
-	}
-
-	if t.Kind() != reflect.Struct {
-		return
-	}
-
-	schemaRef.Value.Required = []string{}
-
-	for i := range t.NumField() {
-		field := t.Field(i)
-		if !field.IsExported() {
-			continue
-		}
-		if field.Anonymous {
-			fieldType := field.Type
-			parseStructTags(fieldType, schemaRef)
-			continue
-		}
-
-		jsonFieldName := field.Tag.Get("json")
-		jsonFieldName = strings.Split(jsonFieldName, ",")[0] // remove omitempty, etc
-		if jsonFieldName == "-" {
-			continue
-		}
-		if jsonFieldName == "" {
-			jsonFieldName = field.Name
-		}
-
-		property := schemaRef.Value.Properties[jsonFieldName]
-		if property == nil {
-			slog.Warn("Property not found in schema", "property", jsonFieldName, "struct", t.Name())
-			continue
-		}
-
-		switch field.Type.Kind() {
-		case reflect.Slice, reflect.Array:
-			arraySchema := property.Value.Items
-			parseStructTags(field.Type.Elem(), arraySchema)
-		case reflect.Ptr, reflect.Map, reflect.Chan, reflect.Func, reflect.UnsafePointer:
-			parseStructTags(field.Type.Elem(), property)
-		case reflect.Struct:
-			parseStructTags(field.Type, property)
-		}
-
-		propertyCopy := *property
-		propertyValue := *propertyCopy.Value
-
-		// Example
-		example, ok := field.Tag.Lookup("example")
-		if ok {
-			switch {
-			case propertyValue.Type.Is(openapi3.TypeInteger):
-				exNum, err := strconv.Atoi(example)
-				if err != nil {
-					slog.Warn("Example might be incorrect (should be integer)", "error", err)
-				}
-				propertyValue.Example = exNum
-			case propertyValue.Type.Is(openapi3.TypeNumber):
-				exNum, err := strconv.ParseFloat(example, 64)
-				if err != nil {
-					slog.Warn("Example might be incorrect (should be floating point number)", "error", err)
-				}
-				propertyValue.Example = exNum
-			case propertyValue.Type.Is(openapi3.TypeBoolean):
-				exBool, err := strconv.ParseBool(example)
-				if err != nil {
-					slog.Warn("Example might be incorrect (should be boolean)", "error", err)
-				}
-				propertyValue.Example = exBool
-			default:
-				propertyValue.Example = example
-			}
-		}
-
-		// Validation
-		validateTag, ok := field.Tag.Lookup("validate")
-		validateTags := strings.Split(validateTag, ",")
-		if ok && slices.Contains(validateTags, "required") {
-			schemaRef.Value.Required = append(schemaRef.Value.Required, jsonFieldName)
-		}
-		for _, validateTag := range validateTags {
-			if strings.HasPrefix(validateTag, "min=") {
-				minValue, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
-				if err != nil {
-					slog.Warn("Min might be incorrect (should be integer)", "error", err)
-				}
-
-				if propertyValue.Type.Is(openapi3.TypeInteger) {
-					minPtr := float64(minValue)
-					propertyValue.Min = &minPtr
-				} else if propertyValue.Type.Is(openapi3.TypeString) {
-					//nolint:gosec // disable G115
-					propertyValue.MinLength = uint64(minValue)
-				}
-			}
-			if strings.HasPrefix(validateTag, "max=") {
-				maxValue, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
-				if err != nil {
-					slog.Warn("Max might be incorrect (should be integer)", "error", err)
-				}
-				if propertyValue.Type.Is(openapi3.TypeInteger) {
-					maxPtr := float64(maxValue)
-					propertyValue.Max = &maxPtr
-				} else if propertyValue.Type.Is(openapi3.TypeString) {
-					//nolint:gosec // disable G115
-					maxPtr := uint64(maxValue)
-					propertyValue.MaxLength = &maxPtr
-				}
-			}
-		}
-
-		// Description
-		description, ok := field.Tag.Lookup("description")
-		if ok {
-			propertyValue.Description = description
-		}
-		jsonTag, ok := field.Tag.Lookup("json")
-		if ok {
-			if strings.Contains(jsonTag, ",omitempty") {
-				propertyValue.Nullable = true
-			}
-		}
-		propertyCopy.Value = &propertyValue
-
-		schemaRef.Value.Properties[jsonFieldName] = &propertyCopy
-	}
-}
-
 type OpenAPIDescriptioner interface {
 	Description() string
 }
diff --git a/openapi_test.go b/openapi_test.go
index c2ac5e8b..c5143e94 100644
--- a/openapi_test.go
+++ b/openapi_test.go
@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"reflect"
 	"testing"
 
 	"github.com/getkin/kin-openapi/openapi3"
@@ -19,11 +20,11 @@ import (
 
 type MyStruct struct {
 	A float64 `json:"asking_price" example:"5.99"`
-	B string  `json:"b"`
-	C int     `json:"c" example:"8" validate:"min=3,max=10" description:"my description"`
+	B string  `json:"b" validate:"required"`
+	C int     `json:"c" example:"8" validate:"min=3,max=10,required" description:"my description"`
 	D bool    `json:"d" example:"true"`
 	F uint64  `json:"f" example:"10"`
-	G int64   `json:"g" example:"-10"`
+	G int64   `json:"g,omitempty" example:"-10"`
 }
 
 type MyStructWithNested struct {
@@ -286,6 +287,26 @@ func Test_tagFromType(t *testing.T) {
 		assert.InDelta(t, float64(10), *c.Value.Max, 0)
 	})
 
+	t.Run("required properties are reflected", func(t *testing.T) {
+		s := NewServer()
+
+		tag := SchemaTagFromType(s.OpenAPI, MyStruct{})
+
+		requiredList := tag.Value.Required
+		assert.Equal(t, []string{"b", "c"}, requiredList)
+	})
+
+	t.Run("ommitempty values are nullable", func(t *testing.T) {
+		s := NewServer()
+
+		tag := SchemaTagFromType(s.OpenAPI, MyStruct{})
+
+		nullableB := tag.Value.Properties["b"].Value.Nullable
+		nullableG := tag.Value.Properties["g"].Value.Nullable
+		assert.False(t, nullableB)
+		assert.True(t, nullableG)
+	})
+
 	t.Run("ensure warnings", func(t *testing.T) {
 		handler := slogassert.New(t, slog.LevelWarn, nil)
 		s := NewServer(
@@ -293,15 +314,6 @@ func Test_tagFromType(t *testing.T) {
 		)
 
 		SchemaTagFromType(s.OpenAPI, InvalidExample{})
-		handler.AssertPrecise(slogassert.LogMessageMatch{
-			Message:       "Property not found in schema",
-			Level:         slog.LevelWarn,
-			AllAttrsMatch: true,
-			Attrs: map[string]any{
-				"property": "XMLName",
-				"struct":   "InvalidExample",
-			},
-		})
 		handler.AssertMessage("Example might be incorrect (should be integer)")
 		handler.AssertMessage("Max might be incorrect (should be integer)")
 		handler.AssertMessage("Min might be incorrect (should be integer)")
@@ -688,3 +700,52 @@ func TestPrivateFieldInStruct(t *testing.T) {
 
 	handler.AssertEmpty() // No warning "Property not found in schema" for the 'password' field
 }
+
+func TestSetGeneratorSchemaCustomizer(t *testing.T) {
+	// A custom function that looks for a custom tag and updates the description
+	customTagParser := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+		myTag, ok := tag.Lookup("my-tag")
+		if !ok {
+			return nil
+		}
+
+		if t.Kind() == reflect.Slice || t.Kind() == reflect.Struct {
+			return nil
+		}
+
+		schema.Description = fmt.Sprintf("Custom tag for %s with tag value: %s", name, myTag)
+		return nil
+	}
+	type Page struct {
+		NumberWords int32 `json:"num_words" my-tag:"The number of words on my page" description:"This should be overwritten"`
+	}
+	type Book struct {
+		Pages []Page `json:"pages" my-tag:"This should not appear in spec"`
+	}
+
+	s := NewServer(WithEngineOptions(WithOpenAPIGeneratorSchemaCustomizer(customTagParser)))
+	Get(s, "/book", func(ContextNoBody) (Book, error) {
+		return Book{}, nil
+	})
+
+	// Get openapi spec
+	openApiSpec := s.OutputOpenAPISpec()
+	require.NotNil(t, openApiSpec)
+	bookValue := openApiSpec.Components.Schemas["Book"].Value
+
+	// Make sure that the pages description is not set because it is a slice
+	pagesValue := bookValue.Properties["pages"].Value
+	require.Empty(t, pagesValue.Description)
+
+	// Make sure that the page description is not set because it is a struct
+	pageValue := pagesValue.Items.Value
+	require.Empty(t, pageValue.Description)
+
+	// Now the num_words should have the custom tag
+	num_wordsValue := pageValue.Properties["num_words"].Value
+	require.NotEmpty(t, num_wordsValue.Description)
+	require.Equal(t,
+		"Custom tag for num_words with tag value: The number of words on my page",
+		num_wordsValue.Description,
+	)
+}
diff --git a/schema_customizer.go b/schema_customizer.go
new file mode 100644
index 00000000..011619a3
--- /dev/null
+++ b/schema_customizer.go
@@ -0,0 +1,173 @@
+package fuego
+
+import (
+	"log/slog"
+	"reflect"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/getkin/kin-openapi/openapi3"
+)
+
+// parseValidate parses the values of the validate tag
+// It adds the following struct tags (tag => OpenAPI schema field):
+// - validate:
+//   - min=1 => min=1 (for integers)
+//   - min=1 => minLength=1 (for strings)
+//   - max=100 => max=100 (for integers)
+//   - max=100 => maxLength=100 (for strings)
+//   - required => (*)
+//
+// (*) The value of required is set in "determineRequired" function.
+// The "required" value is set using the "x-fuego-required-marker" temporary extension value.
+func parseValidate(tag reflect.StructTag, schema *openapi3.Schema) {
+	validateTag, ok := tag.Lookup("validate")
+	if !ok {
+		return
+	}
+
+	validateTags := strings.Split(validateTag, ",")
+	for _, validateTag := range validateTags {
+		// Mark what fields are needed for required. This will be removed later
+		if validateTag == "required" {
+			if schema.Extensions == nil {
+				schema.Extensions = make(map[string]interface{})
+			}
+			schema.Extensions["x-fuego-required-marker"] = true
+		}
+		if strings.HasPrefix(validateTag, "min=") {
+			minValue, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
+			if err != nil {
+				slog.Warn("Min might be incorrect (should be integer)", "error", err)
+			}
+
+			if schema.Type.Is(openapi3.TypeInteger) {
+				minPtr := float64(minValue)
+				schema.Min = &minPtr
+			} else if schema.Type.Is(openapi3.TypeString) {
+				//nolint:gosec // disable G115
+				schema.MinLength = uint64(minValue)
+			}
+		}
+		if strings.HasPrefix(validateTag, "max=") {
+			maxValue, err := strconv.Atoi(strings.Split(validateTag, "=")[1])
+			if err != nil {
+				slog.Warn("Max might be incorrect (should be integer)", "error", err)
+			}
+			if schema.Type.Is(openapi3.TypeInteger) {
+				maxPtr := float64(maxValue)
+				schema.Max = &maxPtr
+			} else if schema.Type.Is(openapi3.TypeString) {
+				//nolint:gosec // disable G115
+				maxPtr := uint64(maxValue)
+				schema.MaxLength = &maxPtr
+			}
+		}
+	}
+}
+
+// determineRequired takes a struct type and determines which properties are
+// required, as determined by checking the extension marker: "x-fuego-required-marker".
+// This marker is set in "parseValidate" and is removed in this function.
+func determineRequired(t reflect.Type, schema *openapi3.Schema) {
+	if t.Kind() != reflect.Struct {
+		return
+	}
+
+	var requiredFields []string
+
+	for name, prop := range schema.Properties {
+		// Check for our marker and remove it
+		if val, ok := prop.Value.Extensions["x-fuego-required-marker"]; ok {
+			if valBool, ok := val.(bool); ok && valBool {
+				requiredFields = append(requiredFields, name)
+			}
+			delete(prop.Value.Extensions, "x-fuego-required-marker")
+		}
+	}
+
+	sort.Strings(requiredFields)
+
+	schema.Required = requiredFields
+}
+
+// parseOmitEmpty parses the "json" tag for the "omitempty" value.
+// If it exists, the schema for the property sets "nullable" to true.
+func parseOmitEmpty(tag reflect.StructTag, schema *openapi3.Schema) {
+	jsonTag, ok := tag.Lookup("json")
+	if ok && strings.Contains(jsonTag, ",omitempty") {
+		schema.Nullable = true
+	}
+}
+
+// parseExample parses the "example" tag and sets the schema example.
+// If the example type does not match the type of the property, it will log a warning.
+func parseExample(tag reflect.StructTag, schema *openapi3.Schema) {
+	example, ok := tag.Lookup("example")
+	if !ok {
+		return
+	}
+
+	switch {
+	case schema.Type.Is(openapi3.TypeInteger):
+		exNum, err := strconv.Atoi(example)
+		if err != nil {
+			slog.Warn("Example might be incorrect (should be integer)", "error", err)
+		}
+		schema.Example = exNum
+	case schema.Type.Is(openapi3.TypeNumber):
+		exNum, err := strconv.ParseFloat(example, 64)
+		if err != nil {
+			slog.Warn("Example might be incorrect (should be floating point number)", "error", err)
+		}
+		schema.Example = exNum
+	case schema.Type.Is(openapi3.TypeBoolean):
+		exBool, err := strconv.ParseBool(example)
+		if err != nil {
+			slog.Warn("Example might be incorrect (should be boolean)", "error", err)
+		}
+		schema.Example = exBool
+	default:
+		schema.Example = example
+	}
+}
+
+// parseDescriptions parses the "description" tag and adds it to the schema description.
+func parseDescription(tag reflect.StructTag, schema *openapi3.Schema) {
+	description, ok := tag.Lookup("description")
+	if ok {
+		schema.Description = description
+	}
+}
+
+// SchemaCustomizer parses struct tags and modifies the schema using kin-openapi3gen's
+// schema customization functionality.
+// It adds the following struct tags (tag => OpenAPI schema field):
+// - description => description
+// - example => example
+// - json => nullable (if contains omitempty)
+// - validate:
+//   - required => required
+//   - min=1 => min=1 (for integers)
+//   - min=1 => minLength=1 (for strings)
+//   - max=100 => max=100 (for integers)
+//   - max=100 => maxLength=100 (for strings)
+func SchemaCustomizer(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+	// Example
+	parseExample(tag, schema)
+
+	// Validation
+	parseValidate(tag, schema)
+
+	// Description
+	parseDescription(tag, schema)
+
+	// Omitempty
+	parseOmitEmpty(tag, schema)
+
+	// After we are done parsing tags, get the required tags
+	determineRequired(t, schema)
+
+	return nil
+}


Reviews:
user: login: dylanhitt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T23:03:42Z
user: login: dylanhitt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T23:05:06Z
user: login: dylanhitt
body: Just some minor nitpicks. This is a great PR. Thank you!

Excellent work!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T03:49:35Z
user: login: EwenQuim
body: LGTM after the comments from dylan!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-21T21:48:57Z
user: login: dylanhitt
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-22T22:00:23Z

Review comments:
diff_hunk: @@ -41,6 +41,21 @@ func (openAPI *OpenAPI) Generator() *openapi3gen.Generator {
 	return openAPI.generator
 }
 
+// Sets the openapi generator with a custom schema customizer function.
+func (openAPI *OpenAPI) SetGeneratorSchemaCustomizer(sc openapi3gen.SchemaCustomizerFn) {
+	// Create a function with the default schema customizer, and one with the provided, thereby merging the two.
+	customizerFn := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+		if err := SchemaCustomizer(name, t, tag, schema); err != nil {
+			return err
+		}
+		if err := sc(name, t, tag, schema); err != nil {
path: openapi.go
user: login: dylanhitt
body: ```go
		return sc(name, t, tag, schema); err != nil {
```
Nit pick. That works probably? Unless i'm not seeing something.
created_at: 2025-05-14T23:01:19Z
author_association: COLLABORATOR
diff_hunk: @@ -19,7 +19,7 @@ func NewOpenAPI() *OpenAPI {
 	desc := NewOpenApiSpec()
 	return &OpenAPI{
 		description:            &desc,
-		generator:              openapi3gen.NewGenerator(),
+		generator:              openapi3gen.NewGenerator(openapi3gen.SchemaCustomizer(SchemaCustomizer)),
path: openapi.go
user: login: dylanhitt
body: 🤔 we should just default to this in general and the provide a engine option to override it.
created_at: 2025-05-14T23:05:06Z
author_association: COLLABORATOR
diff_hunk: @@ -688,3 +700,54 @@ func TestPrivateFieldInStruct(t *testing.T) {
 
 	handler.AssertEmpty() // No warning "Property not found in schema" for the 'password' field
 }
+
+func TestSetGeneratorSchemaCustomizer(t *testing.T) {
+	// A custom function that looks for a custom tag and updates the description
+	customTagParser := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+		myTag, ok := tag.Lookup("my-tag")
+		if !ok {
+			return nil
+		}
+
+		fmt.Println(t.Kind())
+
+		if t.Kind() == reflect.Slice || t.Kind() == reflect.Struct {
+			return nil
+		}
+
+		schema.Description = fmt.Sprintf("Custom tag for %s with tag value: %s", name, myTag)
+		return nil
+	}
+	type Page struct {
+		NumberWords int32 `json:"num_words" my-tag:"The number of words on my page" description:"This should be overwritten"`
+	}
+	type Book struct {
+		Pages []Page `json:"pages" my-tag:"This should not appear in spec"`
+	}
+
+	s := NewServer(WithEngineOptions(WithOpenAPIGeneratorSchemaCustomizer(customTagParser)))
+	Get(s, "/book", func(ContextNoBody) (Book, error) {
+		return Book{}, nil
+	})
+
+	// Get openapi spec
+	openApiSpec := s.OutputOpenAPISpec()
+	require.NotNil(t, openApiSpec)
+	bookValue := openApiSpec.Components.Schemas["Book"].Value
+
+	// Make sure that the pages description is not set because it is a slice
+	pagesValue := bookValue.Properties["pages"].Value
+	require.Empty(t, pagesValue.Description)
+
+	// Make sure that the page description is not set because it is a struct
+	pageValue := pagesValue.Items.Value
+	require.Empty(t, pageValue.Description)
+
+	// Now the num_words should have the custom tag
+	num_wordsValue := pageValue.Properties["num_words"].Value
+	require.NotEmpty(t, num_wordsValue.Description)
+	require.Equal(t,
+		fmt.Sprintf("Custom tag for num_words with tag value: The number of words on my page"),
path: openapi_test.go
user: login: dylanhitt
body: ```suggestion
		"Custom tag for num_words with tag value: The number of words on my page",
```

no need for Sprintf
created_at: 2025-05-21T03:39:39Z
author_association: COLLABORATOR
diff_hunk: @@ -688,3 +700,54 @@ func TestPrivateFieldInStruct(t *testing.T) {
 
 	handler.AssertEmpty() // No warning "Property not found in schema" for the 'password' field
 }
+
+func TestSetGeneratorSchemaCustomizer(t *testing.T) {
+	// A custom function that looks for a custom tag and updates the description
+	customTagParser := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error {
+		myTag, ok := tag.Lookup("my-tag")
+		if !ok {
+			return nil
+		}
+
+		fmt.Println(t.Kind())
path: openapi_test.go
user: login: dylanhitt
body: ```suggestion
```

Looks left over from debugging?
created_at: 2025-05-21T03:41:22Z
author_association: COLLABORATOR

PR comments:
user: login: dylanhitt
created_at: 2025-05-14T18:51:05Z
body: By the way. I do plan on looking at this soon. 
author_association: COLLABORATOR
user: login: dylanhitt
created_at: 2025-05-14T23:10:12Z
body: Not a full review just a glance. (sorry life has been busy)
author_association: COLLABORATOR
user: login: dylanhitt
created_at: 2025-05-21T03:50:23Z
body: @EwenQuim I'm going to approve this some time next week, hopefully you can get around to looking at it. 
author_association: COLLABORATOR
user: login: A-Stone-Olguin
created_at: 2025-05-22T02:04:14Z
body: @EwenQuim @dylanhitt  Thank you both so much for reviewing, I appreciate it! I have pushed the suggested changes. 

My branch is currently a bit out of date with main. Do you have a preference for rebasing vs. merging to bring it up to date? I'm happy to follow whatever approach you prefer for this codebase.
author_association: NONE
user: login: dylanhitt
created_at: 2025-05-22T02:52:55Z
body: Welp was on mobile and accidentally hit update with merge. Which I personally prefer rebasing to update. 

Kinda want to set that as the only way to update honestly. 
author_association: COLLABORATOR