Skip to content

Commit

Permalink
Merge pull request #101 from macisamuele/maci-fix-99-file-uploads
Browse files Browse the repository at this point in the history
File uploads tests
  • Loading branch information
macisamuele committed Feb 7, 2020
2 parents af953eb + e15d240 commit d689512
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 9 deletions.
7 changes: 7 additions & 0 deletions plugin/src/main/java/com/yelp/codegen/SharedCodegen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,13 @@ abstract class SharedCodegen : DefaultCodegen(), CodegenConfig {
codegenOperation.vendorExtensions[X_UNSAFE_OPERATION] = true
}
codegenOperation.isMultipart = isMultipartOperation(operation)

if (!codegenOperation.isMultipart && codegenOperation.allParams.any { it.isFile }) {
// According to the swagger specifications in order to send files the operation must
// consume multipart/form-data (https://swagger.io/docs/specification/2-0/file-upload/)
codegenOperation.vendorExtensions[X_UNSAFE_OPERATION] = true
}

return codegenOperation
}

Expand Down
6 changes: 2 additions & 4 deletions plugin/src/main/resources/kotlin/retrofit2/api.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,8 @@ interface {{classname}} {
{{/vendorExtensions.hasOperationHeaders}}
@{{httpMethod}}("{{{path}}}"){{#vendorExtensions.x-unsafe-operation}}{{#isDeprecated}}
@Deprecated(message = "Deprecated and unsafe to use"){{/isDeprecated}}{{^isDeprecated}}
@Deprecated(message = "Unsafe to use"){{/isDeprecated}}
{{/vendorExtensions.x-unsafe-operation}}{{^vendorExtensions.x-unsafe-operation}}{{#isDeprecated}}
@Deprecated(message = "Deprecated"){{/isDeprecated}}
{{/vendorExtensions.x-unsafe-operation}}
@Deprecated(message = "Unsafe to use"){{/isDeprecated}}{{/vendorExtensions.x-unsafe-operation}}{{^vendorExtensions.x-unsafe-operation}}{{#isDeprecated}}
@Deprecated(message = "Deprecated"){{/isDeprecated}}{{/vendorExtensions.x-unsafe-operation}}
{{#vendorExtensions.x-function-qualifiers}}{{vendorExtensions.x-function-qualifiers}} {{/vendorExtensions.x-function-qualifiers}}fun {{operationId}}({{#hasParams}}
{{#allParams}} {{>retrofit2/queryParams}}{{>retrofit2/pathParams}}{{>retrofit2/headerParams}}{{>retrofit2/bodyParams}}{{>retrofit2/formParams}}{{#hasMore}},{{{newline}}}{{/hasMore}}{{/allParams}}
{{/hasParams}}): {{{returnType}}}
Expand Down
56 changes: 55 additions & 1 deletion samples/junit-tests/junit_tests_specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -575,11 +575,65 @@
},
"/post/file": {
"post": {
"consumes": [
"multipart/form-data"
],
"operationId": "post_file",
"parameters": [
{
"in": "formData",
"name": "file",
"name": "client_file",
"required": true,
"type": "file"
}
],
"responses": {
"default": {
"description": "Something"
}
},
"tags": [
"file"
]
}
},
"/post/file_without_consumes": {
"post": {
"operationId": "post_file_without_multipart_form_data",
"parameters": [
{
"in": "formData",
"name": "client_file",
"required": true,
"type": "file"
}
],
"responses": {
"default": {
"description": "Something"
}
},
"tags": [
"file"
]
}
},
"/post/multiple_files": {
"post": {
"consumes": [
"multipart/form-data"
],
"operationId": "post_multiple_files",
"parameters": [
{
"in": "formData",
"name": "client_file",
"required": true,
"type": "file"
},
{
"in": "formData",
"name": "certificate_file",
"required": true,
"type": "file"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,41 @@ import retrofit2.http.POST
interface FileApi {
/**
* The endpoint is owned by junittests service owner
* @param file (required)
* @param clientFile (required)
*/
@retrofit2.http.FormUrlEncoded
@retrofit2.http.Multipart
@Headers(
"X-Operation-ID: post_file"
)
@POST("/post/file")
fun postFile(
@retrofit2.http.Field("file\"; filename=\"file") file: RequestBody
@retrofit2.http.Part("client_file\"; filename=\"client_file") clientFile: RequestBody
): Completable
/**
* The endpoint is owned by junittests service owner
* @param clientFile (required)
*/
@retrofit2.http.FormUrlEncoded
@Headers(
"X-Operation-ID: post_file_without_multipart_form_data"
)
@POST("/post/file_without_consumes")
@Deprecated(message = "Unsafe to use")
fun postFileWithoutMultipartFormData(
@retrofit2.http.Field("client_file\"; filename=\"client_file") clientFile: RequestBody
): Completable
/**
* The endpoint is owned by junittests service owner
* @param clientFile (required)
* @param certificateFile (required)
*/
@retrofit2.http.Multipart
@Headers(
"X-Operation-ID: post_multiple_files"
)
@POST("/post/multiple_files")
fun postMultipleFiles(
@retrofit2.http.Part("client_file\"; filename=\"client_file") clientFile: RequestBody,
@retrofit2.http.Part("certificate_file\"; filename=\"certificate_file") certificateFile: RequestBody
): Completable
}
2 changes: 1 addition & 1 deletion samples/junit-tests/src/main/java/swagger.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.yelp.codegen.generatecodesamples

import com.yelp.codegen.generatecodesamples.apis.FileApi
import com.yelp.codegen.generatecodesamples.tools.MockServerApiRule
import com.yelp.codegen.generatecodesamples.tools.MultiPartInfo
import com.yelp.codegen.generatecodesamples.tools.decodeMultiPartBody
import okhttp3.MediaType
import okhttp3.RequestBody
import okhttp3.mockwebserver.MockResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class PostFileEndpointTest {

@get:Rule
val rule = MockServerApiRule()

@Test
fun sendFileTest() {
rule.server.enqueue(MockResponse())

rule.getApi<FileApi>().postFile(
clientFile = RequestBody.create(MediaType.parse("application/json"), "{}")
).blockingGet()

val fileApiStats = rule.server.takeRequest().decodeMultiPartBody()
assertNotNull(fileApiStats.boundary)
assertEquals(
listOf(
MultiPartInfo(
contentDisposition = "form-data; name=\"client_file\"; filename=\"client_file\"",
contentType = "application/json; charset=utf-8",
contentLength = "2",
fileContent = "{}"
)
),
fileApiStats.parts
)
}

@Test
fun sendMultipleFilesTest() {
rule.server.enqueue(MockResponse())

rule.getApi<FileApi>().postMultipleFiles(
clientFile = RequestBody.create(MediaType.parse("application/json"), "{}"),
certificateFile = RequestBody.create(MediaType.parse("text/plain"), "Some Text")
).blockingGet()

val fileApiStats = rule.server.takeRequest().decodeMultiPartBody()
assertNotNull(fileApiStats.boundary)
assertEquals(
listOf(
MultiPartInfo(
contentDisposition = "form-data; name=\"client_file\"; filename=\"client_file\"",
contentType = "application/json; charset=utf-8",
contentLength = "2",
fileContent = "{}"
),
MultiPartInfo(
contentDisposition = "form-data; name=\"certificate_file\"; filename=\"certificate_file\"",
contentType = "text/plain; charset=utf-8",
contentLength = "9",
fileContent = "Some Text"
)
),
fileApiStats.parts
)
}

@Test
fun ensureThatEndpointsWithFileAndNotMultipartFormDataAreMarkedAsDeprecated() {
assertTrue(
FileApi::class.java.methods
.first { it.name == "postFileWithoutMultipartFormData" }
.getAnnotationsByType(Deprecated::class.java)
.isNotEmpty()
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.yelp.codegen.generatecodesamples.tools

import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue

data class FileApiStats(
val boundary: String,
val parts: List<MultiPartInfo>
)

data class MultiPartInfo(
val fileContent: String? = null,
val contentDisposition: String? = null,
val contentLength: String? = null,
val contentType: String? = null
)

private fun processPart(part: String): MultiPartInfo {
// part would look like "\r\nHeader-Name: Header-Value\r\n...\r\n\r\nbody\r\n"
// Here we're removing the first and last part as we know that are empty
val partLines = part.split("\r\n").drop(1).dropLast(1)

val indexBoundaryHeadersContent = partLines.indexOf("")

val headers = partLines.subList(0, indexBoundaryHeadersContent).map {
val (name, value) = it.split(": ", limit = 2)
// Note: in theory we can have the same header name multiples times
// We're not dealing with it as this is a test utility ;)
name to value
}.toMap()

val content = partLines.subList(indexBoundaryHeadersContent, partLines.size).drop(1).joinToString("\r\n")
return MultiPartInfo(
fileContent = content,
contentDisposition = headers["Content-Disposition"],
contentType = headers["Content-Type"],
contentLength = headers["Content-Length"]
)
}

/**
* Extract statistics from the body of the recorded request (assuming that it is multipart body)
*/
fun RecordedRequest.decodeMultiPartBody(): FileApiStats {
/**
* requestBody has to be compliant to the HTTP Specifications for MultiPart (RFC 2387)
*
* This means that the body will look like
* --<BOUNDARY>\r\n
* <Content1 Header Name>: <Content1 Header Value>\r\n
* [<Content1 Header Name>: <Content1 Header Value>\r\n]
* \r\n
* <Content1>\r\n
* --<BOUNDARY>\r\n
* <Content# Header Name>: <Content# Header Value>\r\n
* [<Content# Header Name>: <Content# Header Value>\r\n]
* \r\n
* <Content#>\r\n
* --<BOUNDARY>--\r\n
* \r\n
*/
val requestBody = this.body.readUtf8()

val boundaryLine = requestBody.split("\r\n").first()

assertTrue(
"Boundary should start with '--'",
boundaryLine.startsWith("--")
)

val boundary = boundaryLine.substring(startIndex = 2)
val bodyParts = requestBody.split(boundaryLine)

assertTrue(
"Start and End Boundaries are different",
bodyParts.last().startsWith("--")
)
assertEquals(
"The last line of the body has to be empty",
"\r\n",
bodyParts.last().substring(startIndex = 2)
)

val parts = bodyParts
.drop(1)
.dropLast(1)
.map { processPart(it) }

return FileApiStats(
boundary = boundary,
parts = parts
)
}

0 comments on commit d689512

Please sign in to comment.