Repository: swaggest/rest (go)

Issue:
Wrong http status assigned to status.FailedPrecondition
**Describe the bug**
`status.InvalidArgument` and `status.FailedPrecondition` are different statuses but are mapped to the same `http.StatusBadRequest`:

https://github.com/swaggest/rest/blob/master/error.go#L130
https://github.com/swaggest/usecase/blob/master/status/status.go#L97

**Expected behavior**
`status.FailedPrecondition`  should be mapped to `http.StatusPreconditionFailed`.

**Additional context**
Related: #145


Pull request:
Improve support for responses without body (304 et al.)
Fixes #146.

This PR introduces `rest.HTTPCodeAsError(304)` to simplify creation of errors with HTTP status.
Advanced generic example of `"/output-csv-writer", outputCSVWriter()` is upgraded to support `ETag` headers.

Patch:
diff --git a/_examples/advanced-generic/output_writer.go b/_examples/advanced-generic/output_writer.go
index 2ac4aeb..fb832f3 100644
--- a/_examples/advanced-generic/output_writer.go
+++ b/_examples/advanced-generic/output_writer.go
@@ -5,19 +5,33 @@ package main
 import (
 	"context"
 	"encoding/csv"
+	"net/http"
 
+	"github.com/swaggest/rest"
 	"github.com/swaggest/usecase"
 	"github.com/swaggest/usecase/status"
 )
 
 func outputCSVWriter() usecase.Interactor {
 	type writerOutput struct {
-		Header string `header:"X-Header" description:"Sample response header."`
+		Header      string `header:"X-Header" description:"Sample response header."`
+		ContentHash string `header:"ETag" description:"Content hash."`
 		usecase.OutputWithEmbeddedWriter
 	}
 
-	u := usecase.NewInteractor(func(ctx context.Context, _ interface{}, out *writerOutput) (err error) {
+	type writerInput struct {
+		ContentHash string `header:"If-None-Match" description:"Content hash."`
+	}
+
+	u := usecase.NewInteractor(func(ctx context.Context, in writerInput, out *writerOutput) (err error) {
+		contentHash := "abc123" // Pretending this is an actual content hash.
+
+		if in.ContentHash == contentHash {
+			return rest.HTTPCodeAsError(http.StatusNotModified)
+		}
+
 		out.Header = "abc"
+		out.ContentHash = contentHash
 
 		c := csv.NewWriter(out)
 		return c.WriteAll([][]string{{"abc", "def", "hij"}, {"klm", "nop", "qrs"}})
@@ -25,7 +39,7 @@ func outputCSVWriter() usecase.Interactor {
 
 	u.SetTitle("Output With Stream Writer")
 	u.SetDescription("Output with stream writer.")
-	u.SetExpectedErrors(status.Internal)
+	u.SetExpectedErrors(status.Internal, rest.HTTPCodeAsError(http.StatusNotModified))
 	u.SetTags("Response")
 
 	return u
diff --git a/_examples/advanced-generic/router.go b/_examples/advanced-generic/router.go
index 041c3f6..9dd5e7e 100644
--- a/_examples/advanced-generic/router.go
+++ b/_examples/advanced-generic/router.go
@@ -47,7 +47,7 @@ func NewRouter() http.Handler {
 
 		return usecase.Interact(func(ctx context.Context, input, output interface{}) error {
 			err := next.Interact(ctx, input, output)
-			if err != nil {
+			if err != nil && err != rest.HTTPCodeAsError(http.StatusNotModified) {
 				log.Printf("usecase %s request (%v) failed: %v\n", name, input, err)
 			}
 
diff --git a/_examples/go.mod b/_examples/go.mod
index 277fdf3..772decd 100644
--- a/_examples/go.mod
+++ b/_examples/go.mod
@@ -37,7 +37,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
-	github.com/swaggest/form/v5 v5.0.2 // indirect
+	github.com/swaggest/form/v5 v5.0.4 // indirect
 	github.com/swaggest/refl v1.1.0 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/vearutop/dynhist-go v1.1.0 // indirect
diff --git a/_examples/go.sum b/_examples/go.sum
index ed20e97..a366d1e 100644
--- a/_examples/go.sum
+++ b/_examples/go.sum
@@ -5,7 +5,6 @@ github.com/bool64/ctxd v1.2.1 h1:hARFteq0zdn4bwfmxLhak3fXFuvtJVKDH2X29VV/2ls=
 github.com/bool64/ctxd v1.2.1/go.mod h1:ZG6QkeGVLTiUl2mxPpyHmFhDzFZCyocr9hluBV3LYuc=
 github.com/bool64/dev v0.1.41/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
 github.com/bool64/dev v0.2.5/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
-github.com/bool64/dev v0.2.22/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
 github.com/bool64/dev v0.2.25 h1:p6euAfe1zLXb1qzLssm0lJnM5KhfUZp/Qjb2dsPkIKU=
 github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
 github.com/bool64/httpmock v0.1.8 h1:plNJylRdMGjNDV8kCuapKTJJS8p6zbvWbR8NG+Z6qAA=
@@ -60,8 +59,8 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/swaggest/assertjson v1.7.0 h1:SKw5Rn0LQs6UvmGrIdaKQbMR1R3ncXm5KNon+QJ7jtw=
 github.com/swaggest/assertjson v1.7.0/go.mod h1:vxMJMehbSVJd+dDWFCKv3QRZKNTpy/ktZKTz9LOEDng=
-github.com/swaggest/form/v5 v5.0.2 h1:TiimP7UX3q1nSI5ZGg6tGMmxg8UJB0JNpaZjXWH6V2E=
-github.com/swaggest/form/v5 v5.0.2/go.mod h1:Ayta1ggwSnDd4zwzv47jQL4RHk7WOTHp65nzwBc0IhU=
+github.com/swaggest/form/v5 v5.0.4 h1:hTg+gjUEZG0vgsDdXOPi7I0Xu5OowXKoLZIGYZsoddg=
+github.com/swaggest/form/v5 v5.0.4/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg=
 github.com/swaggest/jsonschema-go v0.3.48 h1:zscQIIh2DlUaPTgCntPOq9s9a5QQTeWcs2QTE6P7nEY=
 github.com/swaggest/jsonschema-go v0.3.48/go.mod h1:67EkOWKuBlpuvfZW1djhCD4ZSGNdXUjrTEWe16S5uEg=
 github.com/swaggest/openapi-go v0.2.29 h1:irfkrid6DPGxncZUdGmDrXbSLqat1QOGwvd5M1OFFbY=
diff --git a/error.go b/error.go
index 90623e0..53c4998 100644
--- a/error.go
+++ b/error.go
@@ -7,6 +7,19 @@ import (
 	"github.com/swaggest/usecase/status"
 )
 
+// HTTPCodeAsError exposes HTTP status code as use case error that can be translated to response status.
+type HTTPCodeAsError int
+
+// Error return HTTP status text.
+func (c HTTPCodeAsError) Error() string {
+	return http.StatusText(int(c))
+}
+
+// HTTPStatus returns HTTP status code.
+func (c HTTPCodeAsError) HTTPStatus() int {
+	return int(c)
+}
+
 // ErrWithHTTPStatus exposes HTTP status code.
 type ErrWithHTTPStatus interface {
 	error
@@ -127,7 +140,7 @@ func HTTPStatusFromCanonicalCode(c status.Code) int {
 	case status.ResourceExhausted:
 		return http.StatusTooManyRequests
 	case status.FailedPrecondition:
-		return http.StatusBadRequest
+		return http.StatusPreconditionFailed
 	case status.Aborted:
 		return http.StatusConflict
 	case status.OutOfRange:
diff --git a/go.mod b/go.mod
index 3a1d6ac..3a95371 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/santhosh-tekuri/jsonschema/v3 v3.1.0
 	github.com/stretchr/testify v1.8.2
 	github.com/swaggest/assertjson v1.7.0
-	github.com/swaggest/form/v5 v5.0.2
+	github.com/swaggest/form/v5 v5.0.4
 	github.com/swaggest/jsonschema-go v0.3.48
 	github.com/swaggest/openapi-go v0.2.29
 	github.com/swaggest/refl v1.1.0
diff --git a/go.sum b/go.sum
index 401459d..fdf0acd 100644
--- a/go.sum
+++ b/go.sum
@@ -74,8 +74,8 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/swaggest/assertjson v1.7.0 h1:SKw5Rn0LQs6UvmGrIdaKQbMR1R3ncXm5KNon+QJ7jtw=
 github.com/swaggest/assertjson v1.7.0/go.mod h1:vxMJMehbSVJd+dDWFCKv3QRZKNTpy/ktZKTz9LOEDng=
-github.com/swaggest/form/v5 v5.0.2 h1:TiimP7UX3q1nSI5ZGg6tGMmxg8UJB0JNpaZjXWH6V2E=
-github.com/swaggest/form/v5 v5.0.2/go.mod h1:Ayta1ggwSnDd4zwzv47jQL4RHk7WOTHp65nzwBc0IhU=
+github.com/swaggest/form/v5 v5.0.4 h1:hTg+gjUEZG0vgsDdXOPi7I0Xu5OowXKoLZIGYZsoddg=
+github.com/swaggest/form/v5 v5.0.4/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg=
 github.com/swaggest/jsonschema-go v0.3.48 h1:zscQIIh2DlUaPTgCntPOq9s9a5QQTeWcs2QTE6P7nEY=
 github.com/swaggest/jsonschema-go v0.3.48/go.mod h1:67EkOWKuBlpuvfZW1djhCD4ZSGNdXUjrTEWe16S5uEg=
 github.com/swaggest/openapi-go v0.2.29 h1:irfkrid6DPGxncZUdGmDrXbSLqat1QOGwvd5M1OFFbY=
diff --git a/response/encoder.go b/response/encoder.go
index f89a36e..d8c3eb3 100644
--- a/response/encoder.go
+++ b/response/encoder.go
@@ -278,8 +278,6 @@ func (h *Encoder) writeJSONResponse(
 
 // WriteErrResponse encodes and writes error to response.
 func (h *Encoder) WriteErrResponse(w http.ResponseWriter, r *http.Request, statusCode int, response interface{}) {
-	contentType := DefaultErrorResponseContentType
-
 	e := jsonEncoderPool.Get().(*jsonEncoder) //nolint:errcheck
 
 	e.buf.Reset()
@@ -292,8 +290,14 @@ func (h *Encoder) WriteErrResponse(w http.ResponseWriter, r *http.Request, statu
 		return
 	}
 
-	w.Header().Set("Content-Length", strconv.Itoa(e.buf.Len()))
-	w.Header().Set("Content-Type", contentType)
+	// Skip statuses that do not allow response body (1xx, 204, 304).
+	if !(statusCode < http.StatusOK || statusCode == http.StatusNoContent || statusCode == http.StatusNotModified) {
+		w.Header().Set("Content-Length", strconv.Itoa(e.buf.Len()))
+
+		contentType := DefaultErrorResponseContentType
+		w.Header().Set("Content-Type", contentType)
+	}
+
 	w.WriteHeader(statusCode)
 
 	if r.Method == http.MethodHead {


Test patch:
diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json
index c0ae4f4..8faaad0 100644
--- a/_examples/advanced-generic/_testdata/openapi.json
+++ b/_examples/advanced-generic/_testdata/openapi.json
@@ -50,8 +50,7 @@
                 "schema":{
                   "anyOf":[
                     {"$ref":"#/components/schemas/AdvancedCustomErr"},
-                    {"$ref":"#/components/schemas/AdvancedAnotherErr"},
-                    {"$ref":"#/components/schemas/AdvancedCustomErr"}
+                    {"$ref":"#/components/schemas/AdvancedAnotherErr"}
                   ]
                 }
               }
@@ -60,6 +59,10 @@
           "409":{
             "description":"Conflict",
             "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          },
+          "412":{
+            "description":"Precondition Failed",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
           }
         },
         "x-forbid-unknown-query":true
@@ -379,10 +382,19 @@
       "get":{
         "tags":["Response"],"summary":"Output With Stream Writer","description":"Output with stream writer.",
         "operationId":"_examples/advanced-generic.outputCSVWriter",
+        "parameters":[
+          {
+            "name":"If-None-Match","in":"header","description":"Content hash.",
+            "schema":{"type":"string","description":"Content hash."}
+          }
+        ],
         "responses":{
           "200":{
             "description":"OK",
             "headers":{
+              "ETag":{
+                "style":"simple","description":"Content hash.","schema":{"type":"string","description":"Content hash."}
+              },
               "X-Header":{
                 "style":"simple","description":"Sample response header.",
                 "schema":{"type":"string","description":"Sample response header."}
@@ -390,6 +402,10 @@
             },
             "content":{"text/csv":{"schema":{"type":"string"}}}
           },
+          "304":{
+            "description":"Not Modified",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          },
           "500":{
             "description":"Internal Server Error",
             "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
diff --git a/_examples/advanced/_testdata/openapi.json b/_examples/advanced/_testdata/openapi.json
index 59e90b6..37a6d36 100644
--- a/_examples/advanced/_testdata/openapi.json
+++ b/_examples/advanced/_testdata/openapi.json
@@ -51,8 +51,7 @@
                 "schema":{
                   "anyOf":[
                     {"$ref":"#/components/schemas/AdvancedCustomErr"},
-                    {"$ref":"#/components/schemas/AdvancedAnotherErr"},
-                    {"$ref":"#/components/schemas/AdvancedCustomErr"}
+                    {"$ref":"#/components/schemas/AdvancedAnotherErr"}
                   ]
                 }
               }
@@ -61,6 +60,10 @@
           "409":{
             "description":"Conflict",
             "content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          },
+          "412":{
+            "description":"Precondition Failed",
+            "content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
           }
         },
         "x-forbid-unknown-query":true
@@ -611,4 +614,4 @@
     },
     "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie"}}
   }
-}
\ No newline at end of file
+}
diff --git a/_examples/task-api/internal/infra/nethttp/integration_test.go b/_examples/task-api/internal/infra/nethttp/integration_test.go
index bc37f8a..8511d35 100644
--- a/_examples/task-api/internal/infra/nethttp/integration_test.go
+++ b/_examples/task-api/internal/infra/nethttp/integration_test.go
@@ -92,7 +92,7 @@ func Test_taskLifeSpan(t *testing.T) {
 	assert.NoError(t, rc.ExpectResponseStatus(http.StatusNoContent))
 	assert.NoError(t, rc.ExpectResponseBody(nil))
 
-	assert.NoError(t, rc.ExpectOtherResponsesStatus(http.StatusBadRequest))
+	assert.NoError(t, rc.ExpectOtherResponsesStatus(http.StatusPreconditionFailed))
 	assert.NoError(t, rc.ExpectOtherResponsesBody([]byte(`{"status":"FAILED_PRECONDITION",`+
 		`"error":"failed precondition: task is already closed"}`)))
 
diff --git a/openapi/collector_test.go b/openapi/collector_test.go
index 7d5ff3b..5ad9f89 100644
--- a/openapi/collector_test.go
+++ b/openapi/collector_test.go
@@ -282,8 +282,7 @@ func TestCollector_Collect_CombineErrors(t *testing.T) {
 					"schema":{
 					  "oneOf":[
 						{"$ref":"#/components/schemas/RestErrResponse"},
-						{"$ref":"#/components/schemas/OpenapiTestAnotherErr"},
-						{"$ref":"#/components/schemas/RestErrResponse"}
+						{"$ref":"#/components/schemas/OpenapiTestAnotherErr"}
 					  ]
 					}
 				  }
@@ -294,6 +293,12 @@ func TestCollector_Collect_CombineErrors(t *testing.T) {
 				"content":{
 				  "application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}
 				}
+			  },
+			  "412":{
+				"description":"Precondition Failed",
+				"content":{
+				  "application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}
+				}
 			  }
 			}
 		  }