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.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/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/_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 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) +}