Repository: swaggest/rest (go)

Issue:
SetExpectedErrors allows only one error per status code
An endpoint may fail for different reasons with the same status code, e.g.:

<img width="511" alt="Screenshot 2022-04-14 at 18 27 30" src="https://user-images.githubusercontent.com/1078546/163442017-b82ed31e-1669-4abc-bcda-d087eae24756.png">
<img width="1654" alt="Screenshot 2022-04-14 at 18 27 52" src="https://user-images.githubusercontent.com/1078546/163442036-b9d6ef05-23a2-40d0-a346-73820793e48e.png">

It would be useful to be able to document multiple reasons an endpoint request can fail with the same status code. In my use case, the client can create a "prediction" via a JSON blob, and there are a million things that can be wrong with it, but most of them will be a BadRequest.

OpenAPI v3 supports this feature via `oneOf`: https://stackoverflow.com/questions/36576447/swagger-specify-two-responses-with-same-code-based-on-optional-parameter

As a separate easier question, the interface for `SetExpectedErrors` takes a list of errors, but there are implicit requirements for those errors to actually appear on the docs, e.g. a `.Status() int` function. Would you be able to clarify what exactly do I need to implement to make this work? Cheers.

Pull request description:
Combine error responses with anyOf/oneOf
Resolves #71.

This PR adds an option to enable alternative responses combinations under same HTTP status code. It is not enabled by default to avoid surprising complications of `anyOf` (such logical constraints may have limited support in tools).

```go
	s.OpenAPICollector.CombineErrors = "anyOf"
```

Once this option is enabled, it is possible to set multiple expected errors in usecase.

```go
	u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, status.AlreadyExists)
```

And then you would still need to update `MakeErrResp` to manage error responses (see https://github.com/swaggest/rest/issues/67#issuecomment-1094403431 for details).
```go
	h.MakeErrResp = func(ctx context.Context, err error) (int, interface{}) {
		code, er := rest.Err(err)

		var ae anotherErr

		if errors.As(err, &ae) {
			return http.StatusBadRequest, ae
		}

		return code, er
	}
```

Patch:
diff --git a/_examples/advanced-generic/error_response.go b/_examples/advanced-generic/error_response.go
index e6cd576..b98213d 100644
--- a/_examples/advanced-generic/error_response.go
+++ b/_examples/advanced-generic/error_response.go
@@ -41,7 +41,16 @@ func errorResponse() usecase.Interactor {
 
 	u.SetTitle("Declare Expected Errors")
 	u.SetDescription("This use case demonstrates documentation of expected errors.")
-	u.SetExpectedErrors(status.InvalidArgument, status.AlreadyExists)
+	u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, status.AlreadyExists)
 
 	return u
 }
+
+// anotherErr is another custom error.
+type anotherErr struct {
+	Foo int `json:"foo"`
+}
+
+func (anotherErr) Error() string {
+	return "foo happened"
+}
diff --git a/_examples/advanced-generic/router.go b/_examples/advanced-generic/router.go
index 236e4c1..f6e5650 100644
--- a/_examples/advanced-generic/router.go
+++ b/_examples/advanced-generic/router.go
@@ -4,6 +4,7 @@ package main
 
 import (
 	"context"
+	"errors"
 	"net/http"
 	"reflect"
 	"strings"
@@ -51,6 +52,8 @@ func NewRouter() http.Handler {
 		}
 	})
 
+	s.OpenAPICollector.CombineErrors = "anyOf"
+
 	s.Use(
 		// Response validator setup.
 		//
@@ -66,6 +69,12 @@ func NewRouter() http.Handler {
 				h.MakeErrResp = func(ctx context.Context, err error) (int, interface{}) {
 					code, er := rest.Err(err)
 
+					var ae anotherErr
+
+					if errors.As(err, &ae) {
+						return http.StatusBadRequest, ae
+					}
+
 					return code, customErr{
 						Message: er.ErrorText,
 						Details: er.Context,
diff --git a/_examples/advanced/error_response.go b/_examples/advanced/error_response.go
index 8f5fff5..f872d39 100644
--- a/_examples/advanced/error_response.go
+++ b/_examples/advanced/error_response.go
@@ -44,7 +44,16 @@ func errorResponse() usecase.Interactor {
 
 	u.SetTitle("Declare Expected Errors")
 	u.SetDescription("This use case demonstrates documentation of expected errors.")
-	u.SetExpectedErrors(status.InvalidArgument, status.AlreadyExists)
+	u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, status.AlreadyExists)
 
 	return u
 }
+
+// anotherErr is another custom error.
+type anotherErr struct {
+	Foo int `json:"foo"`
+}
+
+func (anotherErr) Error() string {
+	return "foo happened"
+}
diff --git a/_examples/advanced/router.go b/_examples/advanced/router.go
index 136b26f..901432d 100644
--- a/_examples/advanced/router.go
+++ b/_examples/advanced/router.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"errors"
 	"net/http"
 	"reflect"
 
@@ -45,6 +46,8 @@ func NewRouter() http.Handler {
 		}
 	})
 
+	s.OpenAPICollector.CombineErrors = "anyOf"
+
 	s.Use(
 		// Response validator setup.
 		//
@@ -60,6 +63,12 @@ func NewRouter() http.Handler {
 				h.MakeErrResp = func(ctx context.Context, err error) (int, interface{}) {
 					code, er := rest.Err(err)
 
+					var ae anotherErr
+
+					if errors.As(err, &ae) {
+						return http.StatusBadRequest, ae
+					}
+
 					return code, customErr{
 						Message: er.ErrorText,
 						Details: er.Context,
diff --git a/_examples/go.mod b/_examples/go.mod
index e929ecb..262175c 100644
--- a/_examples/go.mod
+++ b/_examples/go.mod
@@ -13,8 +13,8 @@ require (
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/stretchr/testify v1.7.1
 	github.com/swaggest/assertjson v1.6.8
-	github.com/swaggest/jsonschema-go v0.3.33
-	github.com/swaggest/openapi-go v0.2.17
+	github.com/swaggest/jsonschema-go v0.3.34
+	github.com/swaggest/openapi-go v0.2.18
 	github.com/swaggest/swgui v1.4.5
 	github.com/swaggest/usecase v1.1.2
 	github.com/valyala/fasthttp v1.35.0
diff --git a/_examples/go.sum b/_examples/go.sum
index 0384f9c..d27f95b 100644
--- a/_examples/go.sum
+++ b/_examples/go.sum
@@ -81,10 +81,10 @@ github.com/swaggest/assertjson v1.6.8 h1:1O/9UI5M+2OJI7BeEWKGj0wTvpRXZt5FkOJ4nRk
 github.com/swaggest/assertjson v1.6.8/go.mod h1:Euf0upn9Vlaf1/llYHTs+Kx5K3vVbpMbsZhth7zlN7M=
 github.com/swaggest/form/v5 v5.0.1 h1:YQH0REX7iMKhtoVPWXREZgbt50VYXNCKK61psnD8Fgo=
 github.com/swaggest/form/v5 v5.0.1/go.mod h1:vdnaSTze7cxVKhWiCabrfm1YeLwWLpb9P941Gxv4FnA=
-github.com/swaggest/jsonschema-go v0.3.33 h1:G9FxhWHdINUXUshHwHr4X2ptdZWEyBBDu53+VY94oy0=
-github.com/swaggest/jsonschema-go v0.3.33/go.mod h1:JAF1nm+uIaMOXktuQepmkiRcgQ5yJk4Ccwx9HVt2cXw=
-github.com/swaggest/openapi-go v0.2.17 h1:oBeSzMN+lxR4AXBu5HjNEBYlxQj29K56GGCUaQDXO5M=
-github.com/swaggest/openapi-go v0.2.17/go.mod h1:llsxcypyeyhFMPx5IPIxdk0ESN6zFcaAT3uEK5tlsmk=
+github.com/swaggest/jsonschema-go v0.3.34 h1:f/ErwRNR+Qx/0QTSSIVqAtS9gnHhzBl7IIHO+XKG1GA=
+github.com/swaggest/jsonschema-go v0.3.34/go.mod h1:JAF1nm+uIaMOXktuQepmkiRcgQ5yJk4Ccwx9HVt2cXw=
+github.com/swaggest/openapi-go v0.2.18 h1:9dTzNe91MoepI5PtHNUgby3R0ZNgTQte+pDh3X5XcDw=
+github.com/swaggest/openapi-go v0.2.18/go.mod h1:xUrd0cNiIfhkSIFwxmMmiw3FLegY/MQzSek3Byp4YjU=
 github.com/swaggest/refl v1.0.2 h1:VmP8smuDS1EzUPn31++TzMi13CAaVJdlWpIxzj0up88=
 github.com/swaggest/refl v1.0.2/go.mod h1:DoiPoBJPYHU6Z9fIA6zXQ9uI6VRL6M8BFX5YFT+ym9g=
 github.com/swaggest/swgui v1.4.5 h1:9kw3Lt+4qNdDwnRgpNSsx3s3BjoW/Inhbn7/38DX6Qk=
diff --git a/go.mod b/go.mod
index e4fd8f7..55aa880 100644
--- a/go.mod
+++ b/go.mod
@@ -12,8 +12,8 @@ require (
 	github.com/stretchr/testify v1.7.1
 	github.com/swaggest/assertjson v1.6.8
 	github.com/swaggest/form/v5 v5.0.1
-	github.com/swaggest/jsonschema-go v0.3.33
-	github.com/swaggest/openapi-go v0.2.17
+	github.com/swaggest/jsonschema-go v0.3.34
+	github.com/swaggest/openapi-go v0.2.18
 	github.com/swaggest/refl v1.0.2
 	github.com/swaggest/usecase v1.1.2
 )
diff --git a/go.sum b/go.sum
index cf25d3a..af41678 100644
--- a/go.sum
+++ b/go.sum
@@ -70,10 +70,10 @@ github.com/swaggest/assertjson v1.6.8 h1:1O/9UI5M+2OJI7BeEWKGj0wTvpRXZt5FkOJ4nRk
 github.com/swaggest/assertjson v1.6.8/go.mod h1:Euf0upn9Vlaf1/llYHTs+Kx5K3vVbpMbsZhth7zlN7M=
 github.com/swaggest/form/v5 v5.0.1 h1:YQH0REX7iMKhtoVPWXREZgbt50VYXNCKK61psnD8Fgo=
 github.com/swaggest/form/v5 v5.0.1/go.mod h1:vdnaSTze7cxVKhWiCabrfm1YeLwWLpb9P941Gxv4FnA=
-github.com/swaggest/jsonschema-go v0.3.33 h1:G9FxhWHdINUXUshHwHr4X2ptdZWEyBBDu53+VY94oy0=
-github.com/swaggest/jsonschema-go v0.3.33/go.mod h1:JAF1nm+uIaMOXktuQepmkiRcgQ5yJk4Ccwx9HVt2cXw=
-github.com/swaggest/openapi-go v0.2.17 h1:oBeSzMN+lxR4AXBu5HjNEBYlxQj29K56GGCUaQDXO5M=
-github.com/swaggest/openapi-go v0.2.17/go.mod h1:llsxcypyeyhFMPx5IPIxdk0ESN6zFcaAT3uEK5tlsmk=
+github.com/swaggest/jsonschema-go v0.3.34 h1:f/ErwRNR+Qx/0QTSSIVqAtS9gnHhzBl7IIHO+XKG1GA=
+github.com/swaggest/jsonschema-go v0.3.34/go.mod h1:JAF1nm+uIaMOXktuQepmkiRcgQ5yJk4Ccwx9HVt2cXw=
+github.com/swaggest/openapi-go v0.2.18 h1:9dTzNe91MoepI5PtHNUgby3R0ZNgTQte+pDh3X5XcDw=
+github.com/swaggest/openapi-go v0.2.18/go.mod h1:xUrd0cNiIfhkSIFwxmMmiw3FLegY/MQzSek3Byp4YjU=
 github.com/swaggest/refl v1.0.2 h1:VmP8smuDS1EzUPn31++TzMi13CAaVJdlWpIxzj0up88=
 github.com/swaggest/refl v1.0.2/go.mod h1:DoiPoBJPYHU6Z9fIA6zXQ9uI6VRL6M8BFX5YFT+ym9g=
 github.com/swaggest/usecase v1.1.2 h1:2LfuSyjYtPtpHnxqPwV87/eunbhGBC5HKdRp8/fINBk=
diff --git a/openapi/collector.go b/openapi/collector.go
index 15054a7..41f6a40 100644
--- a/openapi/collector.go
+++ b/openapi/collector.go
@@ -20,7 +20,13 @@ import (
 type Collector struct {
 	mu sync.Mutex
 
-	BasePath    string // URL path to docs, default "/docs/".
+	BasePath string // URL path to docs, default "/docs/".
+
+	// CombineErrors can take a value of "oneOf" or "anyOf",
+	// if not empty it enables logical schema grouping in case
+	// of multiple responses with same HTTP status code.
+	CombineErrors string
+
 	gen         *openapi3.Reflector
 	annotations map[string][]func(*openapi3.Operation) error
 }
@@ -180,12 +186,11 @@ func setRequestMapping(oc *openapi3.OperationContext, mapping rest.RequestMappin
 
 func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor, h rest.HandlerTrait) error {
 	var (
-		hasName           usecase.HasName
-		hasTitle          usecase.HasTitle
-		hasDescription    usecase.HasDescription
-		hasTags           usecase.HasTags
-		hasExpectedErrors usecase.HasExpectedErrors
-		hasDeprecated     usecase.HasIsDeprecated
+		hasName        usecase.HasName
+		hasTitle       usecase.HasTitle
+		hasDescription usecase.HasDescription
+		hasTags        usecase.HasTags
+		hasDeprecated  usecase.HasIsDeprecated
 	)
 
 	if usecase.As(u, &hasName) {
@@ -208,24 +213,67 @@ func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor,
 		op.WithDeprecated(true)
 	}
 
-	if usecase.As(u, &hasExpectedErrors) {
-		for _, e := range hasExpectedErrors.ExpectedErrors() {
-			var (
-				errResp    interface{}
-				statusCode int
-			)
-
-			if h.MakeErrResp != nil {
-				statusCode, errResp = h.MakeErrResp(context.Background(), e)
-			} else {
-				statusCode, errResp = rest.Err(e)
-			}
+	return c.processExpectedErrors(op, u, h)
+}
 
-			err := c.Reflector().SetJSONResponse(op, errResp, statusCode)
-			if err != nil {
-				return err
+func (c *Collector) processExpectedErrors(op *openapi3.Operation, u usecase.Interactor, h rest.HandlerTrait) error {
+	var (
+		errsByCode        = map[int][]interface{}{}
+		statusCodes       []int
+		hasExpectedErrors usecase.HasExpectedErrors
+	)
+
+	if !usecase.As(u, &hasExpectedErrors) {
+		return nil
+	}
+
+	for _, e := range hasExpectedErrors.ExpectedErrors() {
+		var (
+			errResp    interface{}
+			statusCode int
+		)
+
+		if h.MakeErrResp != nil {
+			statusCode, errResp = h.MakeErrResp(context.Background(), e)
+		} else {
+			statusCode, errResp = rest.Err(e)
+		}
+
+		if errsByCode[statusCode] == nil {
+			statusCodes = append(statusCodes, statusCode)
+		}
+
+		errsByCode[statusCode] = append(errsByCode[statusCode], errResp)
+
+		err := c.Reflector().SetJSONResponse(op, errResp, statusCode)
+		if err != nil {
+			return err
+		}
+	}
+
+	for _, statusCode := range statusCodes {
+		var (
+			errResps = errsByCode[statusCode]
+			err      error
+		)
+
+		if len(errResps) == 1 || c.CombineErrors == "" {
+			err = c.Reflector().SetJSONResponse(op, errResps[0], statusCode)
+		} else {
+			switch c.CombineErrors {
+			case "oneOf":
+				err = c.Reflector().SetJSONResponse(op, jsonschema.OneOf(errResps...), statusCode)
+			case "anyOf":
+				err = c.Reflector().SetJSONResponse(op, jsonschema.AnyOf(errResps...), statusCode)
+			default:
+				return errors.New("oneOf/anyOf expected for openapi.Collector.CombineErrors, " +
+					c.CombineErrors + " received")
 			}
 		}
+
+		if err != nil {
+			return err
+		}
 	}
 
 	return nil


Test patch:
diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json
index 6693f83..e842594 100644
--- a/_examples/advanced-generic/_testdata/openapi.json
+++ b/_examples/advanced-generic/_testdata/openapi.json
@@ -19,7 +19,17 @@
           },
           "400":{
             "description":"Bad Request",
-            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+            "content":{
+              "application/json":{
+                "schema":{
+                  "anyOf":[
+                    {"$ref":"#/components/schemas/AdvancedCustomErr"},
+                    {"$ref":"#/components/schemas/AdvancedAnotherErr"},
+                    {"$ref":"#/components/schemas/AdvancedCustomErr"}
+                  ]
+                }
+              }
+            }
           },
           "409":{
             "description":"Conflict",
@@ -412,6 +422,7 @@
   },
   "components":{
     "schemas":{
+      "AdvancedAnotherErr":{"type":"object","properties":{"foo":{"type":"integer"}}},
       "AdvancedCustomErr":{"type":"object","properties":{"details":{"type":"object","additionalProperties":{}},"msg":{"type":"string"}}},
       "AdvancedGzipPassThroughStruct":{
         "type":"object",
diff --git a/_examples/advanced/_testdata/openapi.json b/_examples/advanced/_testdata/openapi.json
index dee02e8..4badc8d 100644
--- a/_examples/advanced/_testdata/openapi.json
+++ b/_examples/advanced/_testdata/openapi.json
@@ -19,7 +19,17 @@
           },
           "400":{
             "description":"Bad Request",
-            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+            "content":{
+              "application/json":{
+                "schema":{
+                  "anyOf":[
+                    {"$ref":"#/components/schemas/AdvancedCustomErr"},
+                    {"$ref":"#/components/schemas/AdvancedAnotherErr"},
+                    {"$ref":"#/components/schemas/AdvancedCustomErr"}
+                  ]
+                }
+              }
+            }
           },
           "409":{
             "description":"Conflict",
@@ -408,6 +418,7 @@
   },
   "components":{
     "schemas":{
+      "AdvancedAnotherErr":{"type":"object","properties":{"foo":{"type":"integer"}}},
       "AdvancedCustomErr":{"type":"object","properties":{"details":{"type":"object","additionalProperties":{}},"msg":{"type":"string"}}},
       "AdvancedGzipPassThroughStruct":{
         "type":"object",
diff --git a/openapi/collector_test.go b/openapi/collector_test.go
index df84406..db1dcf6 100644
--- a/openapi/collector_test.go
+++ b/openapi/collector_test.go
@@ -1,7 +1,9 @@
 package openapi_test
 
 import (
+	"context"
 	"encoding/json"
+	"errors"
 	"mime/multipart"
 	"net/http"
 	"net/http/httptest"
@@ -186,3 +188,89 @@ func TestCollector_Collect_requestMapping(t *testing.T) {
 	}
 	assert.NoError(t, collector.ProvideRequestJSONSchemas(http.MethodPost, new(input), mapping, val))
 }
+
+// anotherErr is another custom error.
+type anotherErr struct {
+	Foo int `json:"foo"`
+}
+
+func (anotherErr) Error() string {
+	return "foo happened"
+}
+
+func TestCollector_Collect_CombineErrors(t *testing.T) {
+	u := usecase.IOInteractor{}
+
+	u.SetTitle("Title")
+	u.SetName("name")
+	u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, status.AlreadyExists)
+
+	h := rest.HandlerTrait{}
+	h.MakeErrResp = func(ctx context.Context, err error) (int, interface{}) {
+		code, er := rest.Err(err)
+
+		var ae anotherErr
+
+		if errors.As(err, &ae) {
+			return http.StatusBadRequest, ae
+		}
+
+		return code, er
+	}
+
+	collector := openapi.Collector{}
+	collector.CombineErrors = "oneOf"
+
+	require.NoError(t, collector.Collect(http.MethodPost, "/test", u, h))
+
+	assertjson.EqualMarshal(t, []byte(`{
+	  "openapi":"3.0.3","info":{"title":"","version":""},
+	  "paths":{
+		"/test":{
+		  "post":{
+			"summary":"Title","description":"","operationId":"name",
+			"responses":{
+			  "204":{"description":"No Content"},
+			  "400":{
+				"description":"Bad Request",
+				"content":{
+				  "application/json":{
+					"schema":{
+					  "oneOf":[
+						{"$ref":"#/components/schemas/RestErrResponse"},
+						{"$ref":"#/components/schemas/OpenapiTestAnotherErr"},
+						{"$ref":"#/components/schemas/RestErrResponse"}
+					  ]
+					}
+				  }
+				}
+			  },
+			  "409":{
+				"description":"Conflict",
+				"content":{
+				  "application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}
+				}
+			  }
+			}
+		  }
+		}
+	  },
+	  "components":{
+		"schemas":{
+		  "OpenapiTestAnotherErr":{"type":"object","properties":{"foo":{"type":"integer"}}},
+		  "RestErrResponse":{
+			"type":"object",
+			"properties":{
+			  "code":{"type":"integer","description":"Application-specific error code."},
+			  "context":{
+				"type":"object","additionalProperties":{},
+				"description":"Application context."
+			  },
+			  "error":{"type":"string","description":"Error message."},
+			  "status":{"type":"string","description":"Status text."}
+			}
+		  }
+		}
+	  }
+	}`), collector.Reflector().SpecEns())
+}