Repository: swaggest/openapi-go
Pull request #94 (2023-12-08 22:24:40)

Fix file uploads in 3.1, remove file uploads nullability in 3.0
<img width="1455" alt="image" src="https://github.com/swaggest/openapi-go/assets/1381436/6ee6c0bf-7de7-4c3f-83e5-4acefb0675bf">


github-actions[bot]: ### Lines Of Code

| Language  |  Files  |   Lines    |    Code    | Comments |  Blanks   | Complexity |     Bytes      |
|-----------|---------|------------|------------|----------|-----------|------------|----------------|
| Go        | 21      | 3869 (+25) | 2886 (+22) | 213      | 770 (+3)  | 782 (+5)   | 89.5K (+663B)  |
| Go (test) | 21 (+2) | 5816 (+80) | 4371 (+58) | 660      | 785 (+22) | 59         | 151.7K (+2K)   |
| JSON      | 18 (+2) | 5605 (+51) | 5605 (+51) | 0        | 0         | 0          | 135.3K (+1.8K) |
<!-- Sticky Pull Request CommentLOC -->
github-actions[bot]: ### Go API Changes

<pre>
# summary
Inferred base version: v0.2.43
Suggested version: v0.2.44
</pre>
<!-- Sticky Pull Request Commentgorelease -->
github-actions[bot]: ### Unit Test Coverage
total:									(statements)				42.1%
changed lines: (statements) 100.0%
<details><summary>Coverage of changed lines</summary>

|           File           |     Function     |  Coverage   |
|--------------------------|------------------|-------------|
| Total                    |                  | 100.0%      |
| openapi3/reflect.go      |                  | 100.0%      |
| openapi3/reflect.go:313  | parseRequestBody | 100.0%      |
| openapi31/reflect.go     |                  | 100.0%      |
| openapi31/reflect.go:260 | parseRequestBody | 100.0%      |
| internal/json_schema.go  |                  | no coverage |

</details>

<details><summary>Coverage diff with base branch</summary>

No changes in coverage.

</details>
<!-- Sticky Pull Request Commentunit-test -->

Patch:
diff --git a/internal/json_schema.go b/internal/json_schema.go
index b90df9b..faa2560 100644
--- a/internal/json_schema.go
+++ b/internal/json_schema.go
@@ -34,6 +34,7 @@ func sanitizeDefName(rc *jsonschema.ReflectContext) {
 
 // ReflectRequestBody reflects JSON schema of request body.
 func ReflectRequestBody(
+	is31 bool, // True if OpenAPI 3.1
 	r *jsonschema.Reflector,
 	cu openapi.ContentUnit,
 	httpMethod string,
@@ -121,21 +122,43 @@ func ReflectRequestBody(
 		jsonschema.PropertyNameMapping(mapping),
 		jsonschema.PropertyNameTag(tag, additionalTags...),
 		sanitizeDefName,
+		jsonschema.InterceptNullability(func(params jsonschema.InterceptNullabilityParams) {
+			if params.NullAdded {
+				vv := reflect.Zero(params.Schema.ReflectType).Interface()
+
+				foundFiles := false
+				if _, ok := vv.([]multipart.File); ok {
+					foundFiles = true
+				}
+
+				if _, ok := vv.([]*multipart.FileHeader); ok {
+					foundFiles = true
+				}
+
+				if foundFiles {
+					params.Schema.RemoveType(jsonschema.Null)
+				}
+			}
+		}),
 		jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) {
 			vv := params.Value.Interface()
 
-			found := false
+			foundFile := false
 			if _, ok := vv.(*multipart.File); ok {
-				found = true
+				foundFile = true
 			}
 
 			if _, ok := vv.(*multipart.FileHeader); ok {
-				found = true
+				foundFile = true
 			}
 
-			if found {
+			if foundFile {
 				params.Schema.AddType(jsonschema.String)
+				params.Schema.RemoveType(jsonschema.Null)
 				params.Schema.WithFormat("binary")
+				if is31 {
+					params.Schema.WithExtraPropertiesItem("contentMediaType", "application/octet-stream")
+				}
 
 				hasFileUpload = true
 
diff --git a/openapi3/reflect.go b/openapi3/reflect.go
index 475ad12..c584e7c 100644
--- a/openapi3/reflect.go
+++ b/openapi3/reflect.go
@@ -321,6 +321,7 @@ func (r *Reflector) parseRequestBody(
 	additionalTags ...string,
 ) error {
 	schema, hasFileUpload, err := internal.ReflectRequestBody(
+		false,
 		r.JSONSchemaReflector(),
 		cu,
 		httpMethod,
diff --git a/openapi31/reflect.go b/openapi31/reflect.go
index 98fd46a..2f1c927 100644
--- a/openapi31/reflect.go
+++ b/openapi31/reflect.go
@@ -268,6 +268,7 @@ func (r *Reflector) parseRequestBody(
 	additionalTags ...string,
 ) error {
 	schema, hasFileUpload, err := internal.ReflectRequestBody(
+		true,
 		r.JSONSchemaReflector(),
 		cu,
 		httpMethod,


Test patch:
diff --git a/openapi3/reflect_deprecated_test.go b/openapi3/reflect_deprecated_test.go
index 265a62b..31ba9b4 100644
--- a/openapi3/reflect_deprecated_test.go
+++ b/openapi3/reflect_deprecated_test.go
@@ -73,7 +73,7 @@ func TestReflector_SetRequest_uploadInterface(t *testing.T) {
 			"type":"object",
 			"properties":{"upload1":{"$ref":"#/components/schemas/MultipartFile"}}
 		  },
-		  "MultipartFile":{"type":"string","format":"binary","nullable":true}
+		  "MultipartFile":{"type":"string","format":"binary"}
 		}
 	  }
 	}`, s)
@@ -342,7 +342,7 @@ func TestReflector_SetupRequest(t *testing.T) {
 		}
 	  },
 	  "components":{
-		"schemas":{"MultipartFile":{"type":"string","format":"binary","nullable":true}}
+		"schemas":{"MultipartFile":{"type":"string","format":"binary"}}
 	  }
 	}`, s)
 }
diff --git a/openapi3/reflect_test.go b/openapi3/reflect_test.go
index a5527d6..5d96109 100644
--- a/openapi3/reflect_test.go
+++ b/openapi3/reflect_test.go
@@ -153,7 +153,7 @@ func TestReflector_AddOperation_uploadInterface(t *testing.T) {
 			"type":"object",
 			"properties":{"upload1":{"$ref":"#/components/schemas/MultipartFile"}}
 		  },
-		  "MultipartFile":{"type":"string","format":"binary","nullable":true}
+		  "MultipartFile":{"type":"string","format":"binary"}
 		}
 	  }
 	}`, reflector.Spec)
@@ -460,7 +460,7 @@ func TestReflector_AddOperation_setup_request(t *testing.T) {
 		}
 	  },
 	  "components":{
-		"schemas":{"MultipartFile":{"type":"string","format":"binary","nullable":true}}
+		"schemas":{"MultipartFile":{"type":"string","format":"binary"}}
 	  }
 	}`, s)
 }
diff --git a/openapi3/testdata/openapi.json b/openapi3/testdata/openapi.json
index da3429d..d937686 100644
--- a/openapi3/testdata/openapi.json
+++ b/openapi3/testdata/openapi.json
@@ -99,8 +99,8 @@
      "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"}
     }
    },
-   "MultipartFile":{"type":"string","format":"binary","nullable":true},
-   "MultipartFileHeader":{"type":"string","format":"binary","nullable":true},
+   "MultipartFile":{"type":"string","format":"binary"},
+   "MultipartFileHeader":{"type":"string","format":"binary"},
    "Openapi3TestReq":{"type":"object","properties":{"in_body1":{"type":"integer"},"in_body2":{"type":"string"}}},
    "Openapi3TestResp":{
     "title":"Sample Response","type":"object",
diff --git a/openapi3/testdata/req_schema.json b/openapi3/testdata/req_schema.json
index bfc5d19..156e139 100644
--- a/openapi3/testdata/req_schema.json
+++ b/openapi3/testdata/req_schema.json
@@ -7,8 +7,8 @@
   "type":"object",
   "components":{
     "schemas":{
-      "MultipartFile":{"type":["string","null"],"format":"binary"},
-      "MultipartFileHeader":{"type":["string","null"],"format":"binary"}
+      "MultipartFile":{"type":"string","format":"binary"},
+      "MultipartFileHeader":{"type":"string","format":"binary"}
     }
   }
 }
diff --git a/openapi3/testdata/uploads.json b/openapi3/testdata/uploads.json
new file mode 100644
index 0000000..7d3e5ea
--- /dev/null
+++ b/openapi3/testdata/uploads.json
@@ -0,0 +1,25 @@
+{
+ "openapi":"3.0.3","info":{"title":"","version":""},
+ "paths":{
+  "/upload":{
+   "post":{
+    "requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi3TestReq"}}}},
+    "responses":{"204":{"description":"No Content"}}
+   }
+  }
+ },
+ "components":{
+  "schemas":{
+   "FormDataOpenapi3TestReq":{
+    "type":"object",
+    "properties":{
+     "upload1":{"$ref":"#/components/schemas/MultipartFile"},
+     "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"},
+     "uploads3":{"type":"array","items":{"$ref":"#/components/schemas/MultipartFile"}},
+     "uploads4":{"type":"array","items":{"$ref":"#/components/schemas/MultipartFileHeader"}}
+    }
+   },
+   "MultipartFile":{"type":"string","format":"binary"},"MultipartFileHeader":{"type":"string","format":"binary"}
+  }
+ }
+}
\ No newline at end of file
diff --git a/openapi3/upload_test.go b/openapi3/upload_test.go
new file mode 100644
index 0000000..df129e7
--- /dev/null
+++ b/openapi3/upload_test.go
@@ -0,0 +1,40 @@
+package openapi3_test
+
+import (
+	"mime/multipart"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/swaggest/assertjson"
+	"github.com/swaggest/openapi-go/openapi3"
+)
+
+func TestNewReflector_uploads(t *testing.T) {
+	r := openapi3.NewReflector()
+
+	oc, err := r.NewOperationContext(http.MethodPost, "/upload")
+	require.NoError(t, err)
+
+	type req struct {
+		Upload1  multipart.File          `formData:"upload1"`
+		Upload2  *multipart.FileHeader   `formData:"upload2"`
+		Uploads3 []multipart.File        `formData:"uploads3"`
+		Uploads4 []*multipart.FileHeader `formData:"uploads4"`
+	}
+
+	oc.AddReqStructure(req{})
+
+	require.NoError(t, r.AddOperation(oc))
+
+	schema, err := assertjson.MarshalIndentCompact(r.SpecSchema(), "", " ", 120)
+	require.NoError(t, err)
+
+	require.NoError(t, os.WriteFile("testdata/uploads_last_run.json", schema, 0o600))
+
+	expected, err := os.ReadFile("testdata/uploads.json")
+	require.NoError(t, err)
+
+	assertjson.Equal(t, expected, schema)
+}
diff --git a/openapi31/reflect_test.go b/openapi31/reflect_test.go
index 7db74e8..2e5b364 100644
--- a/openapi31/reflect_test.go
+++ b/openapi31/reflect_test.go
@@ -155,7 +155,7 @@ func TestReflector_AddOperation_uploadInterface(t *testing.T) {
 			"properties":{"upload1":{"$ref":"#/components/schemas/MultipartFile"}},
 			"type":"object"
 		  },
-		  "MultipartFile":{"format":"binary","type":["null","string"]}
+		  "MultipartFile":{"format":"binary","type":"string","contentMediaType": "application/octet-stream"}
 		}
 	  }
 	}`, reflector.Spec)
@@ -477,7 +477,7 @@ func TestReflector_AddOperation_setup_request(t *testing.T) {
 		  }
 		}
 	  },
-	  "components":{"schemas":{"MultipartFile":{"format":"binary","type":["null","string"]}}}
+	  "components":{"schemas":{"MultipartFile":{"format":"binary","type":"string", "contentMediaType": "application/octet-stream"}}}
 	}`, s)
 }
 
diff --git a/openapi31/testdata/openapi.json b/openapi31/testdata/openapi.json
index f4e7745..9e2cccd 100644
--- a/openapi31/testdata/openapi.json
+++ b/openapi31/testdata/openapi.json
@@ -99,8 +99,8 @@
     },
     "type":"object"
    },
-   "MultipartFile":{"format":"binary","type":["null","string"]},
-   "MultipartFileHeader":{"format":"binary","type":["null","string"]},
+   "MultipartFile":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"},
+   "MultipartFileHeader":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"},
    "Openapi31TestReq":{"properties":{"in_body1":{"type":"integer"},"in_body2":{"type":"string"}},"type":"object"},
    "Openapi31TestResp":{
     "description":"This is a sample response.",
diff --git a/openapi31/testdata/req_schema.json b/openapi31/testdata/req_schema.json
index 2f36cc1..06ed22a 100644
--- a/openapi31/testdata/req_schema.json
+++ b/openapi31/testdata/req_schema.json
@@ -7,8 +7,8 @@
   "type":"object",
   "components":{
     "schemas":{
-      "MultipartFile":{"type":["null","string"],"format":"binary"},
-      "MultipartFileHeader":{"type":["null","string"],"format":"binary"}
+      "MultipartFile":{"type":"string","format":"binary","contentMediaType": "application/octet-stream"},
+      "MultipartFileHeader":{"type":"string","format":"binary","contentMediaType": "application/octet-stream"}
     }
   }
 }
diff --git a/openapi31/testdata/uploads.json b/openapi31/testdata/uploads.json
new file mode 100644
index 0000000..801eeb4
--- /dev/null
+++ b/openapi31/testdata/uploads.json
@@ -0,0 +1,26 @@
+{
+ "openapi":"3.1.0","info":{"title":"","version":""},
+ "paths":{
+  "/upload":{
+   "post":{
+    "requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi31TestReq"}}}},
+    "responses":{"204":{"description":"No Content"}}
+   }
+  }
+ },
+ "components":{
+  "schemas":{
+   "FormDataOpenapi31TestReq":{
+    "properties":{
+     "upload1":{"$ref":"#/components/schemas/MultipartFile"},
+     "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"},
+     "uploads3":{"items":{"$ref":"#/components/schemas/MultipartFile"},"type":"array"},
+     "uploads4":{"items":{"$ref":"#/components/schemas/MultipartFileHeader"},"type":"array"}
+    },
+    "type":"object"
+   },
+   "MultipartFile":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"},
+   "MultipartFileHeader":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}
+  }
+ }
+}
\ No newline at end of file
diff --git a/openapi31/upload_test.go b/openapi31/upload_test.go
new file mode 100644
index 0000000..979476d
--- /dev/null
+++ b/openapi31/upload_test.go
@@ -0,0 +1,40 @@
+package openapi31_test
+
+import (
+	"mime/multipart"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/swaggest/assertjson"
+	"github.com/swaggest/openapi-go/openapi31"
+)
+
+func TestNewReflector_uploads(t *testing.T) {
+	r := openapi31.NewReflector()
+
+	oc, err := r.NewOperationContext(http.MethodPost, "/upload")
+	require.NoError(t, err)
+
+	type req struct {
+		Upload1  multipart.File          `formData:"upload1"`
+		Upload2  *multipart.FileHeader   `formData:"upload2"`
+		Uploads3 []multipart.File        `formData:"uploads3"`
+		Uploads4 []*multipart.FileHeader `formData:"uploads4"`
+	}
+
+	oc.AddReqStructure(req{})
+
+	require.NoError(t, r.AddOperation(oc))
+
+	schema, err := assertjson.MarshalIndentCompact(r.SpecSchema(), "", " ", 120)
+	require.NoError(t, err)
+
+	require.NoError(t, os.WriteFile("testdata/uploads_last_run.json", schema, 0o600))
+
+	expected, err := os.ReadFile("testdata/uploads.json")
+	require.NoError(t, err)
+
+	assertjson.Equal(t, expected, schema)
+}