Repository: danielgtaylor/huma
Pull request #456 (2024-05-29 11:20:07)

feat: allow modifying generated schema by implementing SchemaTransformer interface
This PR introduces the `SchemaTransformer` interface which can be implemented to arbitrarily modify the schema for a type after it is generated (either as the default or by a `SchemaProvider`).

This allows leveraging the default generation and modify only parts of it, instead of explicitly defining a complete schema.

```go
type SchemaTransformer interface {
	TransformSchema(r Registry, s *Schema) *Schema
}
```

Example use case: 
```go

// Struct that defines schemas for its property, to be reused by a SchemaTransformer
type ExampleInputStruct struct {
	Name    string `json:"name" minLength:"2" example:"Jane Doe"`
	Email   string `json:"email" format:"email" doc:"Contact e-mail address"`
	Age     *int   `json:"age,omitempty" minimum:"0"`
	Comment string `json:"comment,omitempty" maxLength:"256"`
}

// Implements SchemaTransformer interface, reusing parts of the schema from `ExampleInputStruct`
type ExampleUpdateStruct struct {
	Name    *string                   `json:"name"`
	Email   *string                   `json:"email" doc:"Override doc for email"`
	Age     OmittableNullable[int]       `json:"age"`
	Comment OmittableNullable[string] `json:"comment"` // Implements SchemaProvider, with Schema.Nullable = true
}

func (u *ExampleUpdateStruct) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema {
	inputSchema := r.Schema(reflect.TypeOf((*ExampleInputStruct)(nil)), false, "")
	for propName, schema := range s.Properties {
		propSchema := inputSchema.Properties[propName]
                // Only doc is overridable in this implementation, but one could implement a more complete schema merging routine
		if schema.Description != "" {
			propSchema.Description = schema.Description
		}
		propSchema.Nullable = schema.Nullable
		s.Properties[propName] = propSchema
	}
	s.Required = []string{} // make everything optional
	return s
}
```

Admittedly, the same could be achieved by implementing `SchemaProvider`, but the implementation would be a bit messier.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced `SchemaTransformer` interface for customizable schema transformations.
  - Enhanced schema definitions to allow `null` values for properties.

- **Bug Fixes**
  - Adjusted schema generation to correctly mark properties as nullable.

- **Tests**
  - Added new tests to validate schema transformations and constraints.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: summarize review in progress by coderabbit.ai -->

> [!NOTE]
> Currently processing new changes in this PR. This may take a few minutes, please wait...
> 
> <details>
> <summary>Commits</summary>
> Files that changed from the base of the PR and between 6019e80758fc8b209c5e1a4e319d429572104f35 and c8717b0fcec1acc7fd9eda3e1b2801b054f55064.
> </details>
> 
> 
> <details>
> <summary>Files selected for processing (3)</summary>
> 
> * registry.go (1 hunks)
> * schema.go (2 hunks)
> * schema_test.go (3 hunks)
> 
> </details>
> 
> ```ascii
>  _____________________________________________
> < Code Wars Episode I: The Phantom Reference. >
>  ---------------------------------------------
>   \
>    \   (\__/)
>        (•ㅅ•)
>        / 　 づ
> ```

<!-- end of auto-generated comment: summarize review in progress by coderabbit.ai --><!-- tips_start -->

---



Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

<details>
<summary>Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
	- `I pushed a fix in commit <commit_id>.`
	- `Generate unit testing code for this file.`
	- `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
	- `@coderabbitai generate unit testing code for this file.`
	-	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
	- `@coderabbitai generate interesting stats about this repository and render them as a table.`
	- `@coderabbitai show all the console.log statements in this repository.`
	- `@coderabbitai read src/utils.ts and generate unit testing code.`
	- `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
	- `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (invoked as PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.


Additionally, you can add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.

### CodeRabbit Configration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://coderabbit.ai/docs) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](https://discord.com/invite/GsXnASn26c) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/danielgtaylor/huma/pull/456?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.81%. Comparing base [(`6019e80`)](https://app.codecov.io/gh/danielgtaylor/huma/commit/6019e80758fc8b209c5e1a4e319d429572104f35?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) to head [(`c8717b0`)](https://app.codecov.io/gh/danielgtaylor/huma/commit/c8717b0fcec1acc7fd9eda3e1b2801b054f55064?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #456      +/-   ##
==========================================
+ Coverage   92.77%   92.81%   +0.04%     
==========================================
  Files          21       21              
  Lines        3571     3577       +6     
==========================================
+ Hits         3313     3320       +7     
+ Misses        220      219       -1     
  Partials       38       38              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/danielgtaylor/huma/pull/456?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).


Patch:
diff --git a/schema.go b/schema.go
index 8a2b6f5..fb5f36e 100644
--- a/schema.go
+++ b/schema.go
@@ -623,6 +623,14 @@ type SchemaProvider interface {
 	Schema(r Registry) *Schema
 }
 
+// SchemaTransformer is an interface that can be implemented by types
+// to transform the generated schema as needed.
+// This can be used to leverage the default schema generation for a type,
+// and arbitrarily modify parts of it.
+type SchemaTransformer interface {
+	TransformSchema(r Registry, s *Schema) *Schema
+}
+
 // SchemaFromType returns a schema for a given type, using the registry to
 // possibly create references for nested structs. The schema that is returned
 // can then be passed to `huma.Validate` to efficiently validate incoming
@@ -632,6 +640,16 @@ type SchemaProvider interface {
 //	registry := huma.NewMapRegistry("#/prefix", huma.DefaultSchemaNamer)
 //	schema := huma.SchemaFromType(registry, reflect.TypeOf(MyType{}))
 func SchemaFromType(r Registry, t reflect.Type) *Schema {
+	s := schemaFromType(r, t)
+	// Transform generated schema if type implements SchemaTransformer
+	v := reflect.New(t).Interface()
+	if st, ok := v.(SchemaTransformer); ok {
+		return st.TransformSchema(r, s)
+	}
+	return s
+}
+
+func schemaFromType(r Registry, t reflect.Type) *Schema {
 	isPointer := t.Kind() == reflect.Pointer
 
 	s := Schema{}


Test patch:
diff --git a/schema_test.go b/schema_test.go
index bad3c15..3623a7d 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -505,7 +505,7 @@ func TestSchema(t *testing.T) {
 				"type": "object",
 				"properties": {
 					"value": {
-						"type": "string",
+						"type": ["string", "null"],
 						"enum": ["foo", "bar"]
 					}
 				},
@@ -945,7 +945,9 @@ func (o *OmittableNullable[T]) UnmarshalJSON(b []byte) error {
 }
 
 func (o OmittableNullable[T]) Schema(r huma.Registry) *huma.Schema {
-	return r.Schema(reflect.TypeOf(o.Value), true, "")
+	s := r.Schema(reflect.TypeOf(o.Value), true, "")
+	s.Nullable = true
+	return s
 }
 
 func TestCustomUnmarshalType(t *testing.T) {
@@ -1119,3 +1121,54 @@ func BenchmarkSchemaErrors(b *testing.B) {
 		}
 	}
 }
+
+// Struct that defines schemas for its property, to be reused by a SchemaTransformer
+type ExampleInputStruct struct {
+	Name    string `json:"name" minLength:"2" example:"Jane Doe"`
+	Email   string `json:"email" format:"email" doc:"Contact e-mail address"`
+	Age     *int   `json:"age,omitempty" minimum:"0"`
+	Comment string `json:"comment,omitempty" maxLength:"256"`
+}
+
+// Implements SchemaTransformer interface, reusing parts of the schema from `ExampleInputStruct`
+type ExampleUpdateStruct struct {
+	Name    *string                   `json:"name"`
+	Email   *string                   `json:"email" doc:"Override doc for email"`
+	Age     OmittableNullable[int]    `json:"age"`
+	Comment OmittableNullable[string] `json:"comment"`
+}
+
+func (u *ExampleUpdateStruct) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema {
+	inputSchema := r.Schema(reflect.TypeOf((*ExampleInputStruct)(nil)), false, "")
+	for propName, schema := range s.Properties {
+		propSchema := inputSchema.Properties[propName]
+		if schema.Description != "" {
+			propSchema.Description = schema.Description
+		}
+		propSchema.Nullable = schema.Nullable
+		s.Properties[propName] = propSchema
+	}
+	s.Required = []string{} // make everything optional
+	return s
+}
+
+func TestSchemaTransformer(t *testing.T) {
+	r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
+	inputSchema := r.Schema(reflect.TypeOf((*ExampleInputStruct)(nil)), false, "")
+	validateSchema := func(s *huma.Schema) {
+		if s.Ref != "" {
+			s = r.SchemaFromRef(s.Ref)
+		}
+		assert.Equal(t, inputSchema.Properties["name"].Examples, s.Properties["name"].Examples)
+		assert.Equal(t, "Override doc for email", s.Properties["email"].Description)
+		assert.Equal(t, inputSchema.Properties["email"].Format, s.Properties["email"].Format)
+		assert.Equal(t, inputSchema.Properties["age"].Minimum, s.Properties["age"].Minimum)
+		assert.True(t, s.Properties["age"].Nullable)
+		assert.Equal(t, inputSchema.Properties["comment"].MaxLength, s.Properties["comment"].MaxLength)
+		assert.True(t, s.Properties["comment"].Nullable)
+	}
+	updateSchema1 := r.Schema(reflect.TypeOf(ExampleUpdateStruct{}), false, "")
+	validateSchema(updateSchema1)
+	updateSchema2 := huma.SchemaFromType(r, reflect.TypeOf(ExampleUpdateStruct{}))
+	validateSchema(updateSchema2)
+}