Repository: danielgtaylor/huma
Pull request #416 (2024-04-26 18:02:32)

Allow providing name hints using tags for anonymous body structs
Small change introducing the possibility to provide name hints for request/response body through struct tags. Previously discussed in #322

Example: 
```go
type EndpointInput struct {
  Body struct {
    SomeData string `json:"some_data"`
  } `name-hint:"SomeName"`
}
```

Because it defines a name hint, it only applies to anonymous types. We could go further and allow overriding the name altogether but that would involve changing the schema namer interface (`func (t reflect.Type, hint string) string`) to include one more parameter, which might break backward compatibility.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/danielgtaylor/huma/pull/416?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
Attention: Patch coverage is `50.00000%` with `4 lines` in your changes are missing coverage. Please review.
> Project coverage is 92.55%. Comparing base [(`ed2c3cd`)](https://app.codecov.io/gh/danielgtaylor/huma/commit/ed2c3cd81698cb5728a49c7cfa267f23c392f644?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) to head [(`45cf03b`)](https://app.codecov.io/gh/danielgtaylor/huma/pull/416?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).

| [Files](https://app.codecov.io/gh/danielgtaylor/huma/pull/416?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) | Patch % | Lines |
|---|---|---|
| [huma.go](https://app.codecov.io/gh/danielgtaylor/huma/pull/416?src=pr&el=tree&filepath=huma.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor#diff-aHVtYS5nbw==) | 50.00% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/danielgtaylor/huma/pull/416?src=pr&el=tree&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     #416      +/-   ##
==========================================
- Coverage   92.65%   92.55%   -0.11%     
==========================================
  Files          21       21              
  Lines        3539     3545       +6     
==========================================
+ Hits         3279     3281       +2     
- Misses        221      223       +2     
- Partials       39       41       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/danielgtaylor/huma/pull/416?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/adapters/humaflow/flow/flow.go b/adapters/humaflow/flow/flow.go
index 22b9083..4a9ceed 100644
--- a/adapters/humaflow/flow/flow.go
+++ b/adapters/humaflow/flow/flow.go
@@ -62,7 +62,6 @@ package flow
 import (
 	"context"
 	"net/http"
-	"net/url"
 	"regexp"
 	"slices"
 	"strings"
@@ -166,7 +165,7 @@ func (m *Mux) Group(fn func(*Mux)) {
 
 // ServeHTTP makes the router implement the http.Handler interface.
 func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	urlSegments := strings.Split(r.URL.EscapedPath(), "/")
+	urlSegments := strings.Split(r.URL.Path, "/")
 	allowedMethods := []string{}
 
 	for _, route := range *m.routes {
@@ -228,20 +227,15 @@ func (r *route) match(ctx context.Context, urlSegments []string) (context.Contex
 		if strings.HasPrefix(routeSegment, ":") {
 			key, rxPattern, containsRx := strings.Cut(strings.TrimPrefix(routeSegment, ":"), "|")
 
-			value, err := url.QueryUnescape(urlSegments[i])
-			if err != nil {
-				return ctx, false
-			}
-
 			if containsRx {
-				if compiledRXPatterns[rxPattern].MatchString(value) {
-					ctx = context.WithValue(ctx, contextKey(key), value)
+				if compiledRXPatterns[rxPattern].MatchString(urlSegments[i]) {
+					ctx = context.WithValue(ctx, contextKey(key), urlSegments[i])
 					continue
 				}
 			}
 
-			if !containsRx && value != "" {
-				ctx = context.WithValue(ctx, contextKey(key), value)
+			if !containsRx && urlSegments[i] != "" {
+				ctx = context.WithValue(ctx, contextKey(key), urlSegments[i])
 				continue
 			}
 
diff --git a/conditional/params.go b/conditional/params.go
index 00c9e5d..2e73d40 100644
--- a/conditional/params.go
+++ b/conditional/params.go
@@ -81,7 +81,7 @@ func (p *Params) PreconditionFailed(etag string, modified time.Time) huma.Status
 			if p.isWrite {
 				errors = append(errors, &huma.ErrorDetail{
 					Message:  "If-None-Match: " + match + " precondition failed, " + foundMsg,
-					Location: "headers.If-None-Match",
+					Location: "request.headers.If-None-Match",
 					Value:    match,
 				})
 			}
@@ -104,7 +104,7 @@ func (p *Params) PreconditionFailed(etag string, modified time.Time) huma.Status
 			if p.isWrite {
 				errors = append(errors, &huma.ErrorDetail{
 					Message:  "If-Match precondition failed, " + foundMsg,
-					Location: "headers.If-Match",
+					Location: "request.headers.If-Match",
 					Value:    p.IfMatch,
 				})
 			}
@@ -117,7 +117,7 @@ func (p *Params) PreconditionFailed(etag string, modified time.Time) huma.Status
 		if p.isWrite {
 			errors = append(errors, &huma.ErrorDetail{
 				Message:  "If-Modified-Since: " + p.IfModifiedSince.Format(http.TimeFormat) + " precondition failed, resource was modified at " + modified.Format(http.TimeFormat),
-				Location: "headers.If-Modified-Since",
+				Location: "request.headers.If-Modified-Since",
 				Value:    p.IfModifiedSince.Format(http.TimeFormat),
 			})
 		}
@@ -129,7 +129,7 @@ func (p *Params) PreconditionFailed(etag string, modified time.Time) huma.Status
 		if p.isWrite {
 			errors = append(errors, &huma.ErrorDetail{
 				Message:  "If-Unmodified-Since: " + p.IfUnmodifiedSince.Format(http.TimeFormat) + " precondition failed, resource was modified at " + modified.Format(http.TimeFormat),
-				Location: "headers.If-Unmodified-Since",
+				Location: "request.headers.If-Unmodified-Since",
 				Value:    p.IfUnmodifiedSince.Format(http.TimeFormat),
 			})
 		}
diff --git a/huma.go b/huma.go
index 20aaa9c..f6b1456 100644
--- a/huma.go
+++ b/huma.go
@@ -582,8 +582,11 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 			if c := f.Tag.Get("contentType"); c != "" {
 				contentType = c
 			}
-
-			s := SchemaFromField(registry, f, getHint(inputType, f.Name, op.OperationID+"Request"))
+			hint := getHint(inputType, f.Name, op.OperationID+"Request")
+			if nameHint := f.Tag.Get("nameHint"); nameHint != "" {
+				hint = nameHint
+			}
+			s := SchemaFromField(registry, f, hint)
 
 			op.RequestBody = &RequestBody{
 				Required: required,
@@ -708,7 +711,11 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 			op.Responses[statusStr].Headers = map[string]*Param{}
 		}
 		if !outBodyFunc {
-			outSchema := SchemaFromField(registry, f, getHint(outputType, f.Name, op.OperationID+"Response"))
+			hint := getHint(outputType, f.Name, op.OperationID+"Response")
+			if nameHint := f.Tag.Get("nameHint"); nameHint != "" {
+				hint = nameHint
+			}
+			outSchema := SchemaFromField(registry, f, hint)
 			if op.Responses[statusStr].Content == nil {
 				op.Responses[statusStr].Content = map[string]*MediaType{}
 			}
diff --git a/schema.go b/schema.go
index d8b9314..accb2a9 100644
--- a/schema.go
+++ b/schema.go
@@ -45,28 +45,6 @@ func deref(t reflect.Type) reflect.Type {
 	return t
 }
 
-// Discriminator object when request bodies or response payloads may be one of a
-// number of different schemas, can be used to aid in serialization,
-// deserialization, and validation. The discriminator is a specific object in a
-// schema which is used to inform the consumer of the document of an alternative
-// schema based on the value associated with it.
-type Discriminator struct {
-	// PropertyName in the payload that will hold the discriminator value.
-	// REQUIRED.
-	PropertyName string `yaml:"propertyName"`
-
-	// Mapping object to hold mappings between payload values and schema names or
-	// references.
-	Mapping map[string]string `yaml:"mapping,omitempty"`
-}
-
-func (d *Discriminator) MarshalJSON() ([]byte, error) {
-	return marshalJSON([]jsonFieldInfo{
-		{"propertyName", d.PropertyName, omitNever},
-		{"mapping", d.Mapping, omitEmpty},
-	}, nil)
-}
-
 // Schema represents a JSON Schema compatible with OpenAPI 3.1. It is extensible
 // with your own custom properties. It supports a subset of the full JSON Schema
 // spec, designed specifically for use with Go structs and to enable fast zero
@@ -121,9 +99,6 @@ type Schema struct {
 	AllOf []*Schema `yaml:"allOf,omitempty"`
 	Not   *Schema   `yaml:"not,omitempty"`
 
-	// OpenAPI specific fields
-	Discriminator *Discriminator `yaml:"discriminator,omitempty"`
-
 	patternRe     *regexp.Regexp  `yaml:"-"`
 	requiredMap   map[string]bool `yaml:"-"`
 	propertyNames []string        `yaml:"-"`
@@ -190,7 +165,6 @@ func (s *Schema) MarshalJSON() ([]byte, error) {
 		{"anyOf", s.AnyOf, omitEmpty},
 		{"allOf", s.AllOf, omitEmpty},
 		{"not", s.Not, omitEmpty},
-		{"discriminator", s.Discriminator, omitEmpty},
 	}, s.Extensions)
 }
 
diff --git a/transforms.go b/transforms.go
index 9c5a0db..c8600c0 100644
--- a/transforms.go
+++ b/transforms.go
@@ -150,8 +150,7 @@ func (t *SchemaLinkTransformer) OnAddOperation(oapi *OpenAPI, op *Operation) {
 // Transform is called for every response to add the `$schema` field and/or
 // the Link header pointing to the JSON Schema.
 func (t *SchemaLinkTransformer) Transform(ctx Context, status string, v any) (any, error) {
-	vv := reflect.ValueOf(v)
-	if vv.Kind() == reflect.Pointer && vv.IsNil() {
+	if v == nil {
 		return v, nil
 	}
 
@@ -185,7 +184,7 @@ func (t *SchemaLinkTransformer) Transform(ctx Context, status string, v any) (an
 	bufPool.Put(buf)
 
 	// Copy over all the exported fields.
-	vv = reflect.Indirect(vv)
+	vv := reflect.Indirect(reflect.ValueOf(v))
 	for i, j := range info.fields {
 		// Field 0 is the $schema field, so we need to offset the index by one.
 		// There might have been unexported fields in the struct declared in the schema,


Test patch:
diff --git a/adapters/humaflow/flow/flow_test.go b/adapters/humaflow/flow/flow_test.go
index 854711e..d6b51f5 100644
--- a/adapters/humaflow/flow/flow_test.go
+++ b/adapters/humaflow/flow/flow_test.go
@@ -95,11 +95,6 @@ func TestMatching(t *testing.T) {
 			"GET", "/path-params/60/beatles/lennon/bar",
 			http.StatusNotFound, map[string]string{"era": "60", "group": "beatles", "member": "lennon"}, "",
 		},
-		{
-			[]string{"GET"}, "/path-params/:era",
-			"GET", "/path-params/a%3A%2F%2Fb%2Fc",
-			http.StatusOK, map[string]string{"era": "a://b/c"}, "",
-		},
 		// regexp
 		{
 			[]string{"GET"}, "/path-params/:era|^[0-9]{2}$/:group|^[a-z].+$",
diff --git a/huma_test.go b/huma_test.go
index 9af8c40..bd2a6ee 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -605,6 +605,25 @@ func TestFeatures(t *testing.T) {
 				assert.Equal(t, http.StatusBadRequest, resp.Code)
 			},
 		},
+		{
+			Name: "request-body-nameHint",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPut,
+					Path:   "/body",
+				}, func(ctx context.Context, input *struct {
+					Body struct {
+						Name string `json:"name"`
+					} `nameHint:"ANameHint"`
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+				assert.Equal(t, "#/components/schemas/ANameHint", api.OpenAPI().Paths["/body"].Put.RequestBody.Content["application/json"].Schema.Ref)
+			},
+			Method: http.MethodPut,
+			URL:    "/body",
+			Body:   `{"name": "Name"}`,
+		},
 		{
 			Name: "request-ptr-body-required",
 			Register: func(t *testing.T, api huma.API) {
@@ -1009,6 +1028,27 @@ Content of example2.txt.
 				assert.Equal(t, "application/custom-type", resp.Header().Get("Content-Type"))
 			},
 		},
+		{
+			Name: "response-body-nameHint",
+			Register: func(t *testing.T, api huma.API) {
+				type Resp struct {
+					Body struct {
+						Greeting string `json:"greeting" `
+					} `nameHint:"GreetingResp"`
+				}
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/response",
+				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
+					resp := &Resp{}
+					resp.Body.Greeting = "Hello, world!"
+					return resp, nil
+				})
+				assert.Equal(t, "#/components/schemas/GreetingResp", api.OpenAPI().Paths["/response"].Get.Responses["200"].Content["application/json"].Schema.Ref)
+			},
+			Method: http.MethodGet,
+			URL:    "/response",
+		},
 		{
 			Name: "response",
 			Register: func(t *testing.T, api huma.API) {
@@ -1114,30 +1154,6 @@ Content of example2.txt.
 				assert.Equal(t, `hello`, resp.Body.String())
 			},
 		},
-		{
-			Name: "response-transform-nil-body",
-			Transformers: []huma.Transformer{
-				huma.NewSchemaLinkTransformer("/", "/").Transform,
-			},
-			Register: func(t *testing.T, api huma.API) {
-				huma.Get(api, "/transform", func(ctx context.Context, i *struct{}) (*struct {
-					Body *struct {
-						Field string `json:"field"`
-					}
-				}, error) {
-					return &struct {
-						Body *struct {
-							Field string `json:"field"`
-						}
-					}{}, nil
-				})
-			},
-			Method: http.MethodGet,
-			URL:    "/transform",
-			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
-				assert.Equal(t, http.StatusOK, resp.Code)
-			},
-		},
 		{
 			Name: "response-transform-error",
 			Transformers: []huma.Transformer{
diff --git a/schema_test.go b/schema_test.go
index bad3c15..4ae63e6 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -985,55 +985,6 @@ func TestCustomUnmarshalType(t *testing.T) {
 	assert.Equal(t, 0, o.Field.Value)
 }
 
-func TestMarshalDiscriminator(t *testing.T) {
-	s := &huma.Schema{
-		OneOf: []*huma.Schema{
-			{Type: "object", Properties: map[string]*huma.Schema{
-				"type": {Type: "string", Enum: []any{"foo"}},
-				"foo":  {Type: "string"},
-			}},
-			{Type: "object", Properties: map[string]*huma.Schema{
-				"type": {Type: "string", Enum: []any{"bar"}},
-				"bar":  {Type: "string"},
-			}},
-		},
-		Discriminator: &huma.Discriminator{
-			PropertyName: "type",
-			Mapping: map[string]string{
-				"foo": "#/components/schemas/Foo",
-				"bar": "#/components/schemas/Bar",
-			},
-		},
-	}
-
-	b, _ := json.Marshal(s)
-	assert.JSONEq(t, `{
-		"oneOf": [
-			{
-				"type": "object",
-				"properties": {
-					"type": {"type": "string", "enum": ["foo"]},
-					"foo": {"type": "string"}
-				}
-			},
-			{
-				"type": "object",
-				"properties": {
-					"type": {"type": "string", "enum": ["bar"]},
-					"bar": {"type": "string"}
-				}
-			}
-		],
-		"discriminator": {
-			"propertyName": "type",
-			"mapping": {
-				"foo": "#/components/schemas/Foo",
-				"bar": "#/components/schemas/Bar"
-			}
-		}
-	}`, string(b))
-}
-
 type BenchSub struct {
 	Visible bool      `json:"visible" default:"true"`
 	Metrics []float64 `json:"metrics" maxItems:"31"`