Repository: getkin/kin-openapi
Pull request #233 (2020-07-10 06:44:15)

conversion: conversion for file upload from v2 to v3 and vice versa
This change enables converting spec for file upload with POST from v2 to v3 and vice versa
according to https://swagger.io/docs/specification/2-0/file-upload/ specification.
Added support for `multipart/form-data`.
Added `in: formData` case for file upload.
Updated unit tests related sections.

gitforbit: Hi @fenollp,

Can you review this please?

Patch:
diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go
index 5654366..dada637 100644
--- a/openapi2conv/openapi2_conv.go
+++ b/openapi2conv/openapi2_conv.go
@@ -121,6 +121,51 @@ func ToV3PathItem(swagger *openapi2.Swagger, pathItem *openapi2.PathItem) (*open
 	return result, nil
 }
 
+func ToV3RequestBodyFormData(parameters []*openapi2.Parameter) *openapi3.RequestBodyRef {
+	if len(parameters) == 0 || parameters[0].In != "formData" {
+		return nil
+	}
+	schema := &openapi3.Schema{
+		Type:       "object",
+		Properties: make(map[string]*openapi3.SchemaRef, len(parameters)),
+	}
+	for _, parameter := range parameters {
+		if parameter.In != "formData" || parameter.Name == "" {
+			continue
+		}
+		format := parameter.Format
+		typ := parameter.Type
+		if parameter.Type == "file" {
+			format = "binary"
+			typ = "string"
+		}
+		pschema := &openapi3.Schema{
+			Description:    parameter.Description,
+			Type:           typ,
+			ExtensionProps: parameter.ExtensionProps,
+			Format:         format,
+			Enum:           parameter.Enum,
+			Min:            parameter.Minimum,
+			Max:            parameter.Maximum,
+			ExclusiveMin:   parameter.ExclusiveMin,
+			ExclusiveMax:   parameter.ExclusiveMax,
+			MinLength:      parameter.MinLength,
+			MaxLength:      parameter.MaxLength,
+			Default:        parameter.Default,
+			Items:          parameter.Items,
+			MinItems:       parameter.MinItems,
+			MaxItems:       parameter.MaxItems,
+		}
+		schemaRef := openapi3.SchemaRef{
+			Value: pschema,
+		}
+		schema.Properties[parameter.Name] = &schemaRef
+	}
+	return &openapi3.RequestBodyRef{
+		Value: openapi3.NewRequestBody().WithFormDataSchema(schema),
+	}
+}
+
 func ToV3Operation(swagger *openapi2.Swagger, pathItem *openapi2.PathItem, operation *openapi2.Operation) (*openapi3.Operation, error) {
 	if operation == nil {
 		return nil, nil
@@ -137,15 +182,21 @@ func ToV3Operation(swagger *openapi2.Swagger, pathItem *openapi2.PathItem, opera
 		resultSecurity := ToV3SecurityRequirements(*v)
 		result.Security = &resultSecurity
 	}
-	for _, parameter := range operation.Parameters {
-		v3Parameter, v3RequestBody, err := ToV3Parameter(parameter)
-		if err != nil {
-			return nil, err
-		}
-		if v3RequestBody != nil {
-			result.RequestBody = v3RequestBody
-		} else if v3Parameter != nil {
-			result.Parameters = append(result.Parameters, v3Parameter)
+
+	requestBodyRef := ToV3RequestBodyFormData(operation.Parameters)
+	if requestBodyRef != nil {
+		result.RequestBody = requestBodyRef
+	} else {
+		for _, parameter := range operation.Parameters {
+			v3Parameter, v3RequestBody, err := ToV3Parameter(parameter)
+			if err != nil {
+				return nil, err
+			}
+			if v3RequestBody != nil {
+				result.RequestBody = v3RequestBody
+			} else if v3Parameter != nil {
+				result.Parameters = append(result.Parameters, v3Parameter)
+			}
 		}
 	}
 	if responses := operation.Responses; responses != nil {
@@ -515,6 +566,42 @@ nameSearch:
 	return ""
 }
 
+func FromV3RequestBodyFormData(requestBodyRef *openapi3.RequestBodyRef) openapi2.Parameters {
+	mediaType := requestBodyRef.Value.GetMediaType("multipart/form-data")
+	if mediaType == nil {
+		return nil
+	}
+	parameters := openapi2.Parameters{}
+	for prop, schemaRef := range mediaType.Schema.Value.Properties {
+		val := schemaRef.Value
+		typ := val.Type
+		if val.Format == "binary" {
+			typ = "file"
+		}
+		parameter := &openapi2.Parameter{
+			Name:           prop,
+			Description:    val.Description,
+			Type:           typ,
+			In:             "formData",
+			ExtensionProps: val.ExtensionProps,
+			Enum:           val.Enum,
+			ExclusiveMin:   val.ExclusiveMin,
+			ExclusiveMax:   val.ExclusiveMax,
+			MinLength:      val.MinLength,
+			MaxLength:      val.MaxLength,
+			Default:        val.Default,
+			Items:          val.Items,
+			MinItems:       val.MinItems,
+			MaxItems:       val.MaxItems,
+			Maximum:        val.Max,
+			Minimum:        val.Min,
+			Pattern:        val.Pattern,
+		}
+		parameters = append(parameters, parameter)
+	}
+	return parameters
+}
+
 func FromV3Operation(swagger *openapi3.Swagger, operation *openapi3.Operation) (*openapi2.Operation, error) {
 	if operation == nil {
 		return nil, nil
@@ -539,11 +626,16 @@ func FromV3Operation(swagger *openapi3.Swagger, operation *openapi3.Operation) (
 		result.Parameters = append(result.Parameters, r)
 	}
 	if v := operation.RequestBody; v != nil {
-		r, err := FromV3RequestBody(swagger, operation, v)
-		if err != nil {
-			return nil, err
+		parameters := FromV3RequestBodyFormData(operation.RequestBody)
+		if len(parameters) > 0 {
+			result.Parameters = append(result.Parameters, parameters...)
+		} else {
+			r, err := FromV3RequestBody(swagger, operation, v)
+			if err != nil {
+				return nil, err
+			}
+			result.Parameters = append(result.Parameters, r)
 		}
-		result.Parameters = append(result.Parameters, r)
 	}
 	if responses := operation.Responses; responses != nil {
 		resultResponses, err := FromV3Responses(responses)
diff --git a/openapi3/content.go b/openapi3/content.go
index 8d187fd..f28912c 100644
--- a/openapi3/content.go
+++ b/openapi3/content.go
@@ -23,6 +23,18 @@ func NewContentWithJSONSchemaRef(schema *SchemaRef) Content {
 	}
 }
 
+func NewContentWithFormDataSchema(schema *Schema) Content {
+	return Content{
+		"multipart/form-data": NewMediaType().WithSchema(schema),
+	}
+}
+
+func NewContentWithFormDataSchemaRef(schema *SchemaRef) Content {
+	return Content{
+		"multipart/form-data": NewMediaType().WithSchemaRef(schema),
+	}
+}
+
 func (content Content) Get(mime string) *MediaType {
 	// If the mime is empty then short-circuit to the wildcard.
 	// We do this here so that we catch only the specific case of
diff --git a/openapi3/request_body.go b/openapi3/request_body.go
index 6e8c8fc..56a055b 100644
--- a/openapi3/request_body.go
+++ b/openapi3/request_body.go
@@ -43,6 +43,16 @@ func (requestBody *RequestBody) WithJSONSchema(value *Schema) *RequestBody {
 	return requestBody
 }
 
+func (requestBody *RequestBody) WithFormDataSchemaRef(value *SchemaRef) *RequestBody {
+	requestBody.Content = NewContentWithFormDataSchemaRef(value)
+	return requestBody
+}
+
+func (requestBody *RequestBody) WithFormDataSchema(value *Schema) *RequestBody {
+	requestBody.Content = NewContentWithFormDataSchema(value)
+	return requestBody
+}
+
 func (requestBody *RequestBody) GetMediaType(mediaType string) *MediaType {
 	m := requestBody.Content
 	if m == nil {
diff --git a/openapi3/swagger_loader.go b/openapi3/swagger_loader.go
index 8376734..cb0cda4 100644
--- a/openapi3/swagger_loader.go
+++ b/openapi3/swagger_loader.go
@@ -240,6 +240,10 @@ func join(basePath *url.URL, relativePath *url.URL) (*url.URL, error) {
 
 func resolvePath(basePath *url.URL, componentPath *url.URL) (*url.URL, error) {
 	if componentPath.Scheme == "" && componentPath.Host == "" {
+		// support absolute paths
+		if componentPath.Path[0] == '/' {
+			return componentPath, nil
+		}
 		return join(basePath, componentPath)
 	}
 	return componentPath, nil


Test patch:
diff --git a/openapi2conv/openapi2_conv_test.go b/openapi2conv/openapi2_conv_test.go
index 536a569..58191ff 100644
--- a/openapi2conv/openapi2_conv_test.go
+++ b/openapi2conv/openapi2_conv_test.go
@@ -161,7 +161,22 @@ const exampleV2 = `
       },
       "post": {
         "description": "example post",
-        "responses": {}
+        "responses": {},
+        "parameters": [
+          {
+            "in": "formData",
+            "name": "fileUpload",
+            "type": "file",
+            "description": "param description",
+            "x-mimetype": "text/plain"
+          },
+          {
+            "in": "formData",
+            "name":"note",
+            "type": "integer",
+            "description": "Description of file contents"
+          }
+        ]
       },
       "put": {
         "description": "example put",
@@ -424,7 +439,28 @@ const exampleV3 = `
       },
       "post": {
         "description": "example post",
-        "responses": {}
+        "responses": {},
+        "requestBody": {
+          "content": {
+            "multipart/form-data": {
+              "schema": {
+                "properties": {
+                  "fileUpload": {
+                    "description": "param description",
+                    "format": "binary",
+                    "type": "string",
+                    "x-mimetype": "text/plain"
+                  },
+                  "note":{
+                    "type": "integer",
+                    "description": "Description of file contents"
+                  }
+                },
+                "type": "object"
+              }
+            }
+          }
+        }
       },
       "put": {
         "description": "example put",