Repository: swaggest/openapi-go
Pull request #47 (2022-10-14 19:18:40)

Ignore json tags if formData is present in request body structure
Currently, if both types of tags are defined on fields, schemas for both `formData` and `json` will be create with separate content types. This not a commonly desired outcome as usually only one type is needed, while `json` can also be defined for other needs.

This PR adds a check to ignore `json` in such case, also it adds a lebeling interface to restore original behavior for edge cases.


Patch:
diff --git a/openapi3/reflect.go b/openapi3/reflect.go
index a340648..439fbb0 100644
--- a/openapi3/reflect.go
+++ b/openapi3/reflect.go
@@ -125,6 +125,13 @@ type RequestBodyEnforcer interface {
 	ForceRequestBody()
 }
 
+// RequestJSONBodyEnforcer enables JSON request body for structures with `formData` tags.
+//
+// Should be implemented on input structure, function body can be empty.
+type RequestJSONBodyEnforcer interface {
+	ForceJSONRequestBody()
+}
+
 func (r *Reflector) parseRequestBody(
 	oc OperationContext, tag, mime string, httpMethod string, mapping map[string]string,
 ) error {
@@ -133,6 +140,7 @@ func (r *Reflector) parseRequestBody(
 
 	httpMethod = strings.ToUpper(httpMethod)
 	_, forceRequestBody := input.(RequestBodyEnforcer)
+	_, forceJSONRequestBody := input.(RequestJSONBodyEnforcer)
 
 	// GET, HEAD, DELETE and TRACE requests should not have body.
 	switch httpMethod {
@@ -149,6 +157,11 @@ func (r *Reflector) parseRequestBody(
 		return nil
 	}
 
+	// If `formData` is defined on a request body `json` is ignored.
+	if tag == tagJSON && refl.HasTaggedFields(input, tagFormData) && !forceJSONRequestBody {
+		return nil
+	}
+
 	// JSON can be a map or array without field tags.
 	if !hasTaggedFields && len(mapping) == 0 && !refl.IsSliceOrMap(input) && refl.FindEmbeddedSliceOrMap(input) == nil {
 		return nil


Test patch:
diff --git a/openapi3/_testdata/req_schema.json b/openapi3/_testdata/req_schema.json
index 872d222..8aaa4b8 100644
--- a/openapi3/_testdata/req_schema.json
+++ b/openapi3/_testdata/req_schema.json
@@ -1,11 +1,14 @@
 {
-  "properties": {
-    "in_body1": {
-      "type": "integer"
-    },
-    "in_body2": {
-      "type": "string"
-    }
+  "properties":{
+    "in_form1":{"type":"string"},"in_form2":{"type":"string"},
+    "upload1":{"$ref":"#/components/schemas/FormDataMultipartFile"},
+    "upload2":{"$ref":"#/components/schemas/FormDataMultipartFileHeader"}
   },
-  "type": "object"
-}
\ No newline at end of file
+  "type":"object",
+  "components":{
+    "schemas":{
+      "FormDataMultipartFile":{"type":["string","null"],"format":"binary"},
+      "FormDataMultipartFileHeader":{"type":["string","null"],"format":"binary"}
+    }
+  }
+}
diff --git a/openapi3/reflect_test.go b/openapi3/reflect_test.go
index a2a4e2d..dec7001 100644
--- a/openapi3/reflect_test.go
+++ b/openapi3/reflect_test.go
@@ -5,6 +5,7 @@ import (
 	"io/ioutil"
 	"mime/multipart"
 	"net/http"
+	"os"
 	"reflect"
 	"strconv"
 	"testing"
@@ -77,6 +78,8 @@ type Req struct {
 	ArraySwg2Pipes []string              `query:"array_swg2_pipes" collectionFormat:"pipes"`
 }
 
+func (r Req) ForceJSONRequestBody() {}
+
 type GetReq struct {
 	InQuery1 int     `query:"in_query1" required:"true" description:"Query parameter." json:"q1"`
 	InQuery3 int     `query:"in_query3" required:"true" description:"Query parameter." json:"q3"`
@@ -202,12 +205,10 @@ func TestReflector_SetJSONResponse(t *testing.T) {
 		WithDescription("Path Description")
 	s.Paths.WithMapOfPathItemValuesItem("/somewhere/{in_path}", pathItem)
 
-	js := op.RequestBody.RequestBody.Content["application/json"].Schema.ToJSONSchema(s)
-	jsb, err := assertjson.MarshalIndentCompact(js, "", " ", 120)
-	require.NoError(t, err)
-	expected, err := ioutil.ReadFile("_testdata/req_schema.json")
+	js := op.RequestBody.RequestBody.Content["multipart/form-data"].Schema.ToJSONSchema(s)
+	expected, err := os.ReadFile("_testdata/req_schema.json")
 	require.NoError(t, err)
-	assertjson.Equal(t, expected, jsb, string(jsb))
+	assertjson.EqualMarshal(t, expected, js)
 
 	assert.NoError(t, s.AddOperation(http.MethodPost, "/somewhere/{in_path}", op))
 
@@ -219,7 +220,7 @@ func TestReflector_SetJSONResponse(t *testing.T) {
 
 	js = op.Responses.MapOfResponseOrRefValues[strconv.Itoa(http.StatusOK)].Response.Content["application/json"].
 		Schema.ToJSONSchema(s)
-	jsb, err = assertjson.MarshalIndentCompact(js, "", " ", 120)
+	jsb, err := assertjson.MarshalIndentCompact(js, "", " ", 120)
 	require.NoError(t, err)
 
 	require.NoError(t, ioutil.WriteFile("_testdata/resp_schema_last_run.json", jsb, 0o600))
@@ -766,3 +767,40 @@ func TestReflector_SetStringResponse(t *testing.T) {
 	err = reflector.SetJSONResponse(&op, new([]WeirdResp), http.StatusConflict)
 	assert.NoError(t, err)
 }
+
+func TestReflector_SetRequest_formData_with_json(t *testing.T) {
+	// In presence of `formData` tags, `json` tags would be ignored as request body.
+	type req struct {
+		Foo int `formData:"foo" json:"foo"`
+	}
+
+	r := openapi3.Reflector{}
+	oc := openapi3.OperationContext{
+		Operation: &openapi3.Operation{},
+		Input:     new(req),
+	}
+
+	require.NoError(t, r.SetupRequest(oc))
+	require.NoError(t, r.SpecEns().AddOperation(http.MethodGet, "/foo", *oc.Operation))
+
+	assertjson.EqualMarshal(t, []byte(`{
+	  "openapi":"3.0.3","info":{"title":"","version":""},
+	  "paths":{
+		"/foo":{
+		  "get":{
+			"requestBody":{
+			  "content":{
+				"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi3TestReq"}}
+			  }
+			},
+			"responses":{"204":{"description":"No Content"}}
+		  }
+		}
+	  },
+	  "components":{
+		"schemas":{
+		  "FormDataOpenapi3TestReq":{"type":"object","properties":{"foo":{"type":"integer"}}}
+		}
+	  }
+	}`), r.SpecEns())
+}