Repository: danielgtaylor/huma
Pull request #163 (2023-11-02 04:31:31)

fix: omit empty string/time headers from response
This PR prevents sending empty string/time headers over the wire, improves documentation, and adds a benchmark comparing global vs. inline/lambda handler functions (which turn out to be about equal in performance).


Patch:
diff --git a/README.md b/README.md
index acdee32..a8eecaa 100644
--- a/README.md
+++ b/README.md
@@ -299,7 +299,7 @@ Anything in the `Extensions` map will be flattened during serialization so that
 
 ### JSON Schema
 
-Using the default Huma config (or manually via the `huma.SchemaLinkTransformer`), each resource operation returns a `describedby` HTTP link relation header which references a JSON-Schema file. These schemas use the `config.SchemasPath` to the serve their content. For example:
+Using the default Huma config (or manually via the `huma.SchemaLinkTransformer`), each resource operation returns a `describedby` HTTP link relation header which references a JSON-Schema file. These schemas use the `config.SchemasPath` to serve their content. For example:
 
 ```http
 Link: </schemas/Note.json>; rel="describedby"
diff --git a/huma.go b/huma.go
index 4a65eef..b7cd89b 100644
--- a/huma.go
+++ b/huma.go
@@ -430,6 +430,21 @@ func transformAndWrite(api API, ctx Context, status int, ct string, body any) {
 // for the request path/query/header parameters and/or body. The output struct
 // must be a  struct with fields for the output headers and body of the
 // operation, if any.
+//
+//	huma.Register(api, huma.Operation{
+//		OperationID: "get-greeting",
+//		Method:      http.MethodGet,
+//		Path:        "/greeting/{name}",
+//		Summary:     "Get a greeting",
+//	}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
+//		if input.Name == "bob" {
+//			return nil, huma.Error404NotFound("no greeting for bob")
+//		}
+//		resp := &GreetingOutput{}
+//		resp.MyHeader = "MyValue"
+//		resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name)
+//		return resp, nil
+//	})
 func Register[I, O any](api API, op Operation, handler func(context.Context, *I) (*O, error)) {
 	oapi := api.OpenAPI()
 	registry := oapi.Components.Schemas
@@ -913,6 +928,10 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 		outHeaders.Every(vo, func(f reflect.Value, info *headerInfo) {
 			switch f.Kind() {
 			case reflect.String:
+				if f.String() == "" {
+					// Don't set empty headers.
+					return
+				}
 				ctx.SetHeader(info.Name, f.String())
 				if info.Name == "Content-Type" {
 					ct = f.String()
@@ -926,7 +945,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 			case reflect.Bool:
 				ctx.SetHeader(info.Name, strconv.FormatBool(f.Bool()))
 			default:
-				if f.Type() == timeType {
+				if f.Type() == timeType && !f.Interface().(time.Time).IsZero() {
 					ctx.SetHeader(info.Name, f.Interface().(time.Time).Format(info.TimeFormat))
 					return
 				}


Test patch:
diff --git a/huma_test.go b/huma_test.go
index 27577d3..a6413c7 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -305,6 +305,7 @@ func TestFeatures(t *testing.T) {
 					Float float64   `header:"float"`
 					Bool  bool      `header:"bool"`
 					Date  time.Time `header:"date"`
+					Empty string    `header:"empty"`
 				}
 
 				huma.Register(api, huma.Operation{
@@ -331,6 +332,30 @@ func TestFeatures(t *testing.T) {
 				assert.Equal(t, "3.45", resp.Header().Get("Float"))
 				assert.Equal(t, "true", resp.Header().Get("Bool"))
 				assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("Date"))
+				assert.Empty(t, resp.Header().Values("Empty"))
+			},
+		},
+		{
+			Name: "response-custom-content-type",
+			Register: func(t *testing.T, api huma.API) {
+				type Resp struct {
+					ContentType string `header:"Content-Type"`
+				}
+
+				huma.Register(api, huma.Operation{
+					Method: http.MethodGet,
+					Path:   "/response-custom-ct",
+				}, func(ctx context.Context, input *struct{}) (*Resp, error) {
+					resp := &Resp{}
+					resp.ContentType = "application/custom-type"
+					return resp, nil
+				})
+			},
+			Method: http.MethodGet,
+			URL:    "/response-custom-ct",
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusNoContent, resp.Code)
+				assert.Equal(t, "application/custom-type", resp.Header().Get("Content-Type"))
 			},
 		},
 		{
@@ -908,3 +933,47 @@ func BenchmarkSecondDecode(b *testing.B) {
 		}
 	})
 }
+
+func globalHandler(ctx context.Context, input *struct {
+	Count int `query:"count"`
+}) (*struct{ Body int }, error) {
+	return &struct{ Body int }{Body: input.Count * 3 / 2}, nil
+}
+
+var BenchmarkHandlerResponse *httptest.ResponseRecorder
+
+// BenchmarkHandlerFunc compares the performance of a global handler function
+// defined via `func name(...) { ... }` versus an inline handler function
+// which is defined as `func(...) { ... }` as an argument to `huma.Register`.
+// Performance should not be impacted much (if any) between the two.
+func BenchmarkHandlerFunc(b *testing.B) {
+	_, api := humatest.New(b, huma.DefaultConfig("Test API", "1.0.0"))
+
+	huma.Register(api, huma.Operation{
+		OperationID: "global",
+		Method:      http.MethodGet,
+		Path:        "/global",
+	}, globalHandler)
+
+	huma.Register(api, huma.Operation{
+		OperationID: "inline",
+		Method:      http.MethodGet,
+		Path:        "/inline",
+	}, func(ctx context.Context, input *struct {
+		Count int `query:"count"`
+	}) (*struct{ Body int }, error) {
+		return &struct{ Body int }{Body: input.Count * 3 / 2}, nil
+	})
+
+	b.Run("global", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			BenchmarkHandlerResponse = api.Get("/global")
+		}
+	})
+
+	b.Run("inline", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			BenchmarkHandlerResponse = api.Get("/inline")
+		}
+	})
+}