Skip to content

Commit

Permalink
Fix handling of request input that can accept form or json (#213)
Browse files Browse the repository at this point in the history
  • Loading branch information
vearutop authored Nov 7, 2024
1 parent 3e4a609 commit 9cc984a
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 0 deletions.
36 changes: 36 additions & 0 deletions _examples/advanced-generic-openapi31/_testdata/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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":[
{
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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":{
Expand Down
36 changes: 36 additions & 0 deletions _examples/advanced-generic-openapi31/form_or_json.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions _examples/advanced-generic-openapi31/form_or_json_test.go
Original file line number Diff line number Diff line change
@@ -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
})
})
}
2 changes: 2 additions & 0 deletions _examples/advanced-generic-openapi31/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions request/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions request/decoder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package request_test

import (
"bytes"
"context"
"fmt"
"net/http"
Expand Down Expand Up @@ -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)
}

0 comments on commit 9cc984a

Please sign in to comment.