Repository: swaggest/rest (go)

Issue:
Response header validation case sensitivity
**Describe the bug**
Accrording to #119 header names should be compared in case insensitive fashion.

When ETag response header is defined with [`ETag`](https://www.rfc-editor.org/rfc/rfc7232#section-2.3) canonical name
```
ETag string `header:"ETag" json:"-" required:"true"`
```
response validation error is thrown (missing etag header value) even if `ETag` field is populated in response struct. No such error when response struct is defined with `Etag` header name

```
ETag string `header:"Etag" json:"-" required:"true"`
```
**Expected behavior**
No response validation error thrown when `header:"ETag"` is used in struct definition.


Pull request description:
Use canonical header names in validation
Fixes #141.

Patch:
diff --git a/_examples/advanced-generic/output_headers.go b/_examples/advanced-generic/output_headers.go
index 8bc326c..41f2227 100644
--- a/_examples/advanced-generic/output_headers.go
+++ b/_examples/advanced-generic/output_headers.go
@@ -6,25 +6,32 @@ import (
 	"context"
 
 	"github.com/swaggest/usecase"
+	"github.com/swaggest/usecase/status"
 )
 
 func outputHeaders() usecase.Interactor {
 	type EmbeddedHeaders struct {
-		Foo int `header:"X-Foo" json:"-"`
+		Foo int `header:"X-foO,omitempty" json:"-" minimum:"10" required:"true" description:"Reduced by 20 in response."`
 	}
 
 	type headerOutput struct {
 		EmbeddedHeaders
-		Header string `header:"X-Header" json:"-" description:"Sample response header."`
-		InBody string `json:"inBody" deprecated:"true"`
-		Cookie int    `cookie:"coo,httponly,path:/foo" json:"-"`
+		Header    string `header:"x-HeAdEr" json:"-" description:"Sample response header."`
+		OmitEmpty int    `header:"x-omit-empty,omitempty" json:"-" description:"Receives req value of X-Foo reduced by 30."`
+		InBody    string `json:"inBody" deprecated:"true"`
+		Cookie    int    `cookie:"coo,httponly,path:/foo" json:"-"`
 	}
 
-	u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, out *headerOutput) (err error) {
+	type headerInput struct {
+		EmbeddedHeaders
+	}
+
+	u := usecase.NewInteractor(func(ctx context.Context, in headerInput, out *headerOutput) (err error) {
 		out.Header = "abc"
 		out.InBody = "def"
 		out.Cookie = 123
-		out.Foo = 321
+		out.Foo = in.Foo - 20
+		out.OmitEmpty = in.Foo - 30
 
 		return nil
 	})
@@ -32,6 +39,7 @@ func outputHeaders() usecase.Interactor {
 	u.SetTitle("Output With Headers")
 	u.SetDescription("Output with headers.")
 	u.SetTags("Response")
+	u.SetExpectedErrors(status.Internal)
 
 	return u
 }
diff --git a/jsonschema/validator.go b/jsonschema/validator.go
index 3a3237a..fa2caf5 100644
--- a/jsonschema/validator.go
+++ b/jsonschema/validator.go
@@ -113,11 +113,11 @@ func (v *Validator) AddSchema(in rest.ParamIn, name string, jsonSchema []byte, r
 		v.inRequired[in] = make([]string, 0)
 	}
 
-	if required {
-		if in == rest.ParamInHeader {
-			name = http.CanonicalHeaderKey(name)
-		}
+	if in == rest.ParamInHeader {
+		name = http.CanonicalHeaderKey(name)
+	}
 
+	if required {
 		v.inRequired[in] = append(v.inRequired[in], name)
 	}
 
diff --git a/openapi/collector.go b/openapi/collector.go
index 474e061..2006fd0 100644
--- a/openapi/collector.go
+++ b/openapi/collector.go
@@ -500,6 +500,8 @@ func (c *Collector) provideHeaderSchemas(resp *openapi3.Response, validator rest
 		}
 
 		if validator != nil {
+			name = http.CanonicalHeaderKey(name)
+
 			err = validator.AddSchema(rest.ParamInHeader, name, schemaData, required)
 			if err != nil {
 				return fmt.Errorf("failed to add validation schema for response header (%s): %w", name, err)
diff --git a/response/encoder.go b/response/encoder.go
index 8fbcfb4..f89a36e 100644
--- a/response/encoder.go
+++ b/response/encoder.go
@@ -71,7 +71,13 @@ func (h *Encoder) setupHeadersEncoder(output interface{}, ht *rest.HandlerTrait)
 		respHeaderMapping = make(map[string]string)
 
 		refl.WalkTaggedFields(reflect.ValueOf(output), func(v reflect.Value, sf reflect.StructField, tag string) {
-			respHeaderMapping[sf.Name] = tag
+			// Converting name to canonical form, while keeping omitempty and any other options.
+			t := sf.Tag.Get(string(rest.ParamInHeader))
+			parts := strings.Split(t, ",")
+			parts[0] = http.CanonicalHeaderKey(parts[0])
+			t = strings.Join(parts, ",")
+
+			respHeaderMapping[sf.Name] = t
 		}, string(rest.ParamInHeader))
 	}
 


Test patch:
diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json
index 7d36a58..c0ae4f4 100644
--- a/_examples/advanced-generic/_testdata/openapi.json
+++ b/_examples/advanced-generic/_testdata/openapi.json
@@ -401,34 +401,68 @@
       "get":{
         "tags":["Response"],"summary":"Output With Headers","description":"Output with headers.",
         "operationId":"_examples/advanced-generic.outputHeaders",
+        "parameters":[
+          {
+            "name":"X-foO","in":"header","description":"Reduced by 20 in response.","required":true,
+            "schema":{"minimum":10,"type":"integer","description":"Reduced by 20 in response."}
+          }
+        ],
         "responses":{
           "200":{
             "description":"OK",
             "headers":{
-              "X-Foo":{"style":"simple","schema":{"type":"integer"}},
-              "X-Header":{
+              "X-foO":{
+                "style":"simple","description":"Reduced by 20 in response.","required":true,
+                "schema":{"minimum":10,"type":"integer","description":"Reduced by 20 in response."}
+              },
+              "x-HeAdEr":{
                 "style":"simple","description":"Sample response header.",
                 "schema":{"type":"string","description":"Sample response header."}
+              },
+              "x-omit-empty":{
+                "style":"simple","description":"Receives req value of X-Foo reduced by 30.",
+                "schema":{"type":"integer","description":"Receives req value of X-Foo reduced by 30."}
               }
             },
             "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedHeaderOutput"}}}
+          },
+          "500":{
+            "description":"Internal Server Error",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
           }
         }
       },
       "head":{
         "tags":["Response"],"summary":"Output With Headers","description":"Output with headers.",
         "operationId":"_examples/advanced-generic.outputHeaders2",
+        "parameters":[
+          {
+            "name":"X-foO","in":"header","description":"Reduced by 20 in response.","required":true,
+            "schema":{"minimum":10,"type":"integer","description":"Reduced by 20 in response."}
+          }
+        ],
         "responses":{
           "200":{
             "description":"OK",
             "headers":{
-              "X-Foo":{"style":"simple","schema":{"type":"integer"}},
-              "X-Header":{
+              "X-foO":{
+                "style":"simple","description":"Reduced by 20 in response.","required":true,
+                "schema":{"minimum":10,"type":"integer","description":"Reduced by 20 in response."}
+              },
+              "x-HeAdEr":{
                 "style":"simple","description":"Sample response header.",
                 "schema":{"type":"string","description":"Sample response header."}
+              },
+              "x-omit-empty":{
+                "style":"simple","description":"Receives req value of X-Foo reduced by 30.",
+                "schema":{"type":"integer","description":"Receives req value of X-Foo reduced by 30."}
               }
             },
             "content":{"application/json":{}}
+          },
+          "500":{
+            "description":"Internal Server Error",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
           }
         }
       }
diff --git a/_examples/advanced-generic/output_headers_test.go b/_examples/advanced-generic/output_headers_test.go
index b412e1f..929135f 100644
--- a/_examples/advanced-generic/output_headers_test.go
+++ b/_examples/advanced-generic/output_headers_test.go
@@ -3,7 +3,7 @@
 package main
 
 import (
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -25,6 +25,7 @@ func Benchmark_outputHeaders(b *testing.B) {
 	httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) {
 		req.Header.SetMethod(http.MethodGet)
 		req.SetRequestURI(srv.URL + "/output-headers")
+		req.Header.Set("X-Foo", "40")
 	}, func(i int, resp *fasthttp.Response) bool {
 		return resp.StatusCode() == http.StatusOK
 	})
@@ -36,16 +37,100 @@ func Test_outputHeaders(t *testing.T) {
 	srv := httptest.NewServer(r)
 	defer srv.Close()
 
-	resp, err := http.Get(srv.URL + "/output-headers")
+	req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil)
+	require.NoError(t, err)
+
+	req.Header.Set("x-FoO", "40")
+
+	resp, err := http.DefaultTransport.RoundTrip(req)
+	require.NoError(t, err)
+
+	assert.Equal(t, resp.StatusCode, http.StatusOK)
+
+	body, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.NoError(t, resp.Body.Close())
+
+	assert.Equal(t, "abc", resp.Header.Get("X-Header"))
+	assert.Equal(t, "20", resp.Header.Get("X-Foo"))
+	assert.Equal(t, "10", resp.Header.Get("X-Omit-Empty"))
+	assert.Equal(t, []string{"coo=123; HttpOnly"}, resp.Header.Values("Set-Cookie"))
+	assertjson.Equal(t, []byte(`{"inBody":"def"}`), body)
+}
+
+func Test_outputHeaders_invalidReq(t *testing.T) {
+	r := NewRouter()
+
+	srv := httptest.NewServer(r)
+	defer srv.Close()
+
+	req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil)
+	require.NoError(t, err)
+
+	req.Header.Set("x-FoO", "5")
+
+	resp, err := http.DefaultTransport.RoundTrip(req)
+	require.NoError(t, err)
+
+	assert.Equal(t, resp.StatusCode, http.StatusBadRequest)
+
+	body, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.NoError(t, resp.Body.Close())
+
+	assertjson.Equal(t,
+		[]byte(`{"msg":"invalid argument: validation failed","details":{"header:X-Foo":["#: must be >= 10/1 but found 5"]}}`),
+		body, string(body))
+}
+
+func Test_outputHeaders_invalidResp(t *testing.T) {
+	r := NewRouter()
+
+	srv := httptest.NewServer(r)
+	defer srv.Close()
+
+	req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil)
+	require.NoError(t, err)
+
+	req.Header.Set("x-FoO", "15")
+
+	resp, err := http.DefaultTransport.RoundTrip(req)
+	require.NoError(t, err)
+
+	assert.Equal(t, resp.StatusCode, http.StatusInternalServerError)
+
+	body, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.NoError(t, resp.Body.Close())
+
+	assertjson.Equal(t,
+		[]byte(`{"msg":"internal: bad response: validation failed","details":{"header:X-Foo":["#: must be >= 10/1 but found -5"]}}`),
+		body, string(body))
+}
+
+func Test_outputHeaders_omitempty(t *testing.T) {
+	r := NewRouter()
+
+	srv := httptest.NewServer(r)
+	defer srv.Close()
+
+	req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil)
+	require.NoError(t, err)
+
+	req.Header.Set("x-FoO", "30")
+
+	resp, err := http.DefaultTransport.RoundTrip(req)
 	require.NoError(t, err)
 
 	assert.Equal(t, resp.StatusCode, http.StatusOK)
 
-	body, err := ioutil.ReadAll(resp.Body)
+	body, err := io.ReadAll(resp.Body)
 	assert.NoError(t, err)
 	assert.NoError(t, resp.Body.Close())
 
 	assert.Equal(t, "abc", resp.Header.Get("X-Header"))
+	assert.Equal(t, "10", resp.Header.Get("X-Foo"))
+	assert.Equal(t, []string(nil), resp.Header.Values("X-Omit-Empty"))
 	assert.Equal(t, []string{"coo=123; HttpOnly"}, resp.Header.Values("Set-Cookie"))
 	assertjson.Equal(t, []byte(`{"inBody":"def"}`), body)
 }
diff --git a/jsonschema/validator_test.go b/jsonschema/validator_test.go
index 992a057..1b30880 100644
--- a/jsonschema/validator_test.go
+++ b/jsonschema/validator_test.go
@@ -77,7 +77,7 @@ func TestFactory_MakeResponseValidator(t *testing.T) {
 			Name  string `json:"name" minLength:"1"`
 			Trace string `maxLength:"3"`
 		}), map[string]string{
-			"Trace": "X-Trace",
+			"Trace": "x-TrAcE",
 		})
 
 	assert.NoError(t, validator.ValidateJSONBody([]byte(`{"name":"John"}`)))