Repository: swaggest/rest (go)

Issue:
Multiple JSON + FormData request validation
**Describe the bug**
I set field **required** with `formData:` and `json:` tags in input request data structure simultaneously. Also I add ForceJSONRequestBody() function for input structure to support multiple request formats (e.g application/json and application/x-www-form-urlencoded).
In this case if I send json request I got validation error 
```go
"formData:field": [
      "missing value"
    ],
```

**To Reproduce**
```go
package main_test

import (
	"bytes"
	"context"
	"github.com/swaggest/openapi-go/openapi31"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/swaggest/rest/web"
	"github.com/swaggest/usecase"
)

type TestRequest struct {
	Field string `json:"field" formData:"field" required:"true"`
}

func (a TestRequest) ForceJSONRequestBody() {}

func TestJSONPlusForm(t *testing.T) {
	service := web.NewService(openapi31.NewReflector())

	path := "/test"

	service.Post(path, usecase.NewInteractor(func(ctx context.Context, input TestRequest, output *string) error {
		*output = input.Field

		return nil
	}))

	schema := service.OpenAPICollector.SpecSchema().(*openapi31.Spec)
	assert.NotNil(t, schema)
	assert.Contains(t, schema.Paths.MapOfPathItemValues, path)
	testPath := schema.Paths.MapOfPathItemValues[path]
	assert.NotNil(t, testPath.Post)
	assert.Contains(t, testPath.Post.RequestBody.RequestBody.Content, "application/x-www-form-urlencoded")
	assert.Contains(t, testPath.Post.RequestBody.RequestBody.Content, "application/json")

	req, err := http.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"field":"some-data"}`)))
	require.NoError(t, err)
	req.Header.Set("Content-Type", "application/json")
	rw := httptest.NewRecorder()

	service.ServeHTTP(rw, req)
	assert.Contains(t, rw.Body.String(), "validation failed")
	assert.Contains(t, rw.Body.String(), `"formData:field":["missing value"]`)
	assert.Equal(t, http.StatusBadRequest, rw.Code)
}
```

Here what's happen:
* There're 2 decoders (form + json) for specified path
* First decoder returns error because form data is empty and next decoder not triggered

**Expected behavior**
Validation should be performed according request type header

**Additional context**
As workaround I can remove required tag


Pull request:
Fix handling of request input that can accept form or json
Fixes #212.

Patch:
diff --git a/_examples/advanced-generic-openapi31/form_or_json.go b/_examples/advanced-generic-openapi31/form_or_json.go
new file mode 100644
index 0000000..b450c00
--- /dev/null
+++ b/_examples/advanced-generic-openapi31/form_or_json.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+	"context"
+
+	"github.com/swaggest/usecase"
+)
+
+type formOrJSONInput struct {
+	Field1 string `json:"field1" formData:"field1" required:"true"`
+	Field2 int    `json:"field2" formData:"field2" required:"true"`
+	Field3 string `path:"path" required:"true"`
+}
+
+func (formOrJSONInput) ForceJSONRequestBody() {}
+
+func formOrJSON() usecase.Interactor {
+	type formOrJSONOutput struct {
+		F1 string `json:"f1"`
+		F2 int    `json:"f2"`
+		F3 string `json:"f3"`
+	}
+
+	u := usecase.NewInteractor(func(ctx context.Context, input formOrJSONInput, output *formOrJSONOutput) error {
+		output.F1 = input.Field1
+		output.F2 = input.Field2
+		output.F3 = input.Field3
+
+		return nil
+	})
+
+	u.SetTags("Request")
+	u.SetDescription("This endpoint can accept both form and json requests with the same input structure.")
+
+	return u
+}
diff --git a/_examples/advanced-generic-openapi31/router.go b/_examples/advanced-generic-openapi31/router.go
index 412c2b7..871b6ee 100644
--- a/_examples/advanced-generic-openapi31/router.go
+++ b/_examples/advanced-generic-openapi31/router.go
@@ -204,6 +204,8 @@ func NewRouter() http.Handler {
 	s.Post("/text-req-body/{path}", textReqBody(), nethttp.RequestBodyContent("text/csv"))
 	s.Post("/text-req-body-ptr/{path}", textReqBodyPtr(), nethttp.RequestBodyContent("text/csv"))
 
+	s.Post("/form-or-json/{path}", formOrJSON())
+
 	// Security middlewares.
 	//  - sessMW is the actual request-level processor,
 	//  - sessDoc is a handler-level wrapper to expose docs.
diff --git a/request/decoder.go b/request/decoder.go
index d9f0a20..1389c05 100644
--- a/request/decoder.go
+++ b/request/decoder.go
@@ -77,6 +77,11 @@ func decodeValidate(d *form.Decoder, v interface{}, p url.Values, in rest.ParamI
 
 func makeDecoder(in rest.ParamIn, formDecoder *form.Decoder, decoderFunc decoderFunc) valueDecoderFunc {
 	return func(r *http.Request, v interface{}, validator rest.Validator) error {
+		ct := r.Header.Get("Content-Type")
+		if in == rest.ParamInFormData && ct != "" && ct != "multipart/form-data" && ct != "application/x-www-form-urlencoded" {
+			return nil
+		}
+
 		values, err := decoderFunc(r)
 		if err != nil {
 			return err


Test patch:
diff --git a/_examples/advanced-generic-openapi31/_testdata/openapi.json b/_examples/advanced-generic-openapi31/_testdata/openapi.json
index 39c980a..7ce0dc7 100644
--- a/_examples/advanced-generic-openapi31/_testdata/openapi.json
+++ b/_examples/advanced-generic-openapi31/_testdata/openapi.json
@@ -161,6 +161,33 @@
     "x-forbid-unknown-query":true
    }
   },
+  "/form-or-json/{path}":{
+   "parameters":[
+    {
+     "name":"X-Umbrella-Header","in":"header","description":"This request header is supported in all operations.",
+     "schema":{"type":"string"}
+    }
+   ],
+   "post":{
+    "tags":["Request"],"summary":"Form Or JSON",
+    "description":"This endpoint can accept both form and json requests with the same input structure.",
+    "operationId":"_examples/advanced-generic-openapi31.formOrJSON",
+    "parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string"}}],
+    "requestBody":{
+     "content":{
+      "application/json":{"schema":{"$ref":"#/components/schemas/AdvancedFormOrJSONInput"}},
+      "application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/FormDataAdvancedFormOrJSONInput"}}
+     }
+    },
+    "responses":{
+     "200":{
+      "description":"OK",
+      "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedFormOrJSONOutput"}}}
+     }
+    },
+    "x-forbid-unknown-path":true
+   }
+  },
   "/gzip-pass-through":{
    "parameters":[
     {
@@ -771,6 +798,11 @@
     "properties":{"bar":{"minLength":3,"type":"string"},"baz":{"minLength":3,"type":["null","string"]}},
     "type":"object"
    },
+   "AdvancedFormOrJSONInput":{
+    "additionalProperties":false,"properties":{"field1":{"type":"string"},"field2":{"type":"integer"}},
+    "required":["field1","field2"],"type":"object"
+   },
+   "AdvancedFormOrJSONOutput":{"properties":{"f1":{"type":"string"},"f2":{"type":"integer"},"f3":{"type":"string"}},"type":"object"},
    "AdvancedGzipPassThroughStruct":{
     "properties":{"id":{"type":"integer"},"text":{"items":{"type":"string"},"type":["array","null"]}},
     "type":"object"
@@ -905,6 +937,10 @@
     "type":"object"
    },
    "FormDataAdvancedForm":{"additionalProperties":false,"properties":{"id":{"type":"integer"},"name":{"type":"string"}},"type":"object"},
+   "FormDataAdvancedFormOrJSONInput":{
+    "additionalProperties":false,"properties":{"field1":{"type":"string"},"field2":{"type":"integer"}},
+    "required":["field1","field2"],"type":"object"
+   },
    "FormDataAdvancedUpload":{
     "additionalProperties":false,
     "properties":{
diff --git a/_examples/advanced-generic-openapi31/form_or_json_test.go b/_examples/advanced-generic-openapi31/form_or_json_test.go
new file mode 100644
index 0000000..c7428ec
--- /dev/null
+++ b/_examples/advanced-generic-openapi31/form_or_json_test.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/bool64/httptestbench"
+	"github.com/valyala/fasthttp"
+)
+
+func Benchmark_formOrJSON(b *testing.B) {
+	r := NewRouter()
+
+	srv := httptest.NewServer(r)
+	defer srv.Close()
+
+	b.Run("form", func(b *testing.B) {
+		httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) {
+			req.Header.SetMethod(http.MethodPost)
+			req.SetRequestURI(srv.URL + "/form-or-json/abc")
+			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+			req.SetBody([]byte(`field1=def&field2=123`))
+		}, func(i int, resp *fasthttp.Response) bool {
+			return resp.StatusCode() == http.StatusOK
+		})
+	})
+
+	b.Run("json", func(b *testing.B) {
+		httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) {
+			req.Header.SetMethod(http.MethodPost)
+			req.SetRequestURI(srv.URL + "/form-or-json/abc")
+			req.Header.Set("Content-Type", "application/json")
+			req.SetBody([]byte(`{"field1":"string","field2":0}`))
+		}, func(i int, resp *fasthttp.Response) bool {
+			return resp.StatusCode() == http.StatusOK
+		})
+	})
+}
diff --git a/request/decoder_test.go b/request/decoder_test.go
index 2fb3f41..4c3cc75 100644
--- a/request/decoder_test.go
+++ b/request/decoder_test.go
@@ -1,6 +1,7 @@
 package request_test
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"net/http"
@@ -566,3 +567,39 @@ func TestDecoderFactory_MakeDecoder_default_unexported(t *testing.T) {
 	dec := f.MakeDecoder(http.MethodGet, showImageInput{}, nil)
 	assert.NotNil(t, dec)
 }
+
+type formOrJSONInput struct {
+	Field1 string `json:"field1" formData:"field1" required:"true"`
+	Field2 int    `json:"field2" formData:"field2" required:"true"`
+}
+
+func (formOrJSONInput) ForceJSONRequestBody() {}
+
+func TestDecoderFactory_MakeDecoder_formOrJSON(t *testing.T) {
+	var in formOrJSONInput
+
+	dec := request.NewDecoderFactory().MakeDecoder(http.MethodPost, in, nil)
+
+	validator := jsonschema.NewFactory(&openapi.Collector{}, &openapi.Collector{}).
+		MakeRequestValidator(http.MethodPost, in, nil)
+
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
+		"/", bytes.NewReader([]byte(`{"field1":"abc","field2":123}`)))
+	assert.NoError(t, err)
+
+	req.Header.Set("Content-Type", "application/json")
+
+	require.NoError(t, dec.Decode(req, &in, validator))
+	assert.Equal(t, "abc", in.Field1)
+	assert.Equal(t, 123, in.Field2)
+
+	in = formOrJSONInput{}
+	req, err = http.NewRequestWithContext(context.Background(), http.MethodPost,
+		"/", bytes.NewReader([]byte(`field1=abc&field2=123`)))
+	assert.NoError(t, err)
+
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	require.NoError(t, dec.Decode(req, &in, validator))
+	assert.Equal(t, "abc", in.Field1)
+	assert.Equal(t, 123, in.Field2)
+}