Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Support Request Parts #271

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ abstract class ApiSpecTaskTest {
"queryParameters" : [ ],
"formParameters" : [ ],
"requestFields" : [ ],
"requestParts" : [ ],
"example" : null,
"securityRequirements" : {
"type": "OAUTH2",
Expand Down Expand Up @@ -150,6 +151,7 @@ abstract class ApiSpecTaskTest {
"queryParameters" : [ ],
"formParameters" : [ ],
"requestFields" : [ ],
"requestParts" : [ ],
"example" : null,
"securityRequirements" : {
"type": "OAUTH2",
Expand Down Expand Up @@ -187,6 +189,7 @@ abstract class ApiSpecTaskTest {
"queryParameters" : [ ],
"formParameters" : [ ],
"requestFields" : [ ],
"requestParts" : [ ],
"example" : null,
"securityRequirements" : null
},
Expand Down Expand Up @@ -234,4 +237,61 @@ abstract class ApiSpecTaskTest {
protected fun givenBuildFileWithoutApiSpecClosure() {
buildFile.writeText(baseBuildFile())
}

fun givenResourceSnippetWithRequestParts() {
val operationDir = File(snippetsFolder, "some-operation").apply { mkdir() }
File(operationDir, "resource.json").writeText(
"""
{
"operationId" : "product-photo-upload",
"summary" : null,
"description" : null,
"privateResource" : false,
"deprecated" : false,
"request" : {
"path" : "/products/photo/{id}",
"method" : "POST",
"contentType" : "multipart/form-data",
"headers" : [ {
"name" : "one",
"attributes" : { },
"description" : "Override request header param",
"type" : "STRING",
"optional" : true,
"example" : "one",
"default" : "a default value"
} ],
"pathParameters" : [ ],
"queryParameters" : [ ],
"formParameters" : [ ],
"requestFields" : [ ],
"requestParts" : [
{
"content" : "dGVzdA==",
"headers" : {
"Content-Type" : [ "image/jpeg" ],
"Content-Length" : [ "123" ]
},
"name" : "photo",
"submittedFileName" : "photo.jpg",
"contentAsString" : "photo"
}
],
"example" : null,
"securityRequirements" : {
"type": "OAUTH2",
"requiredScopes": ["prod:r"]
}
},
"response" : {
"status" : 200,
"contentType" : "application/hal+json",
"headers" : [ ],
"responseFields" : [ ],
"example" : "{\n \"name\" : \"Fancy pants\",\n \"price\" : 49.99,\n \"_links\" : {\n \"self\" : {\n \"href\" : \"http://localhost:8080/products/7\"\n },\n \"product\" : {\n \"href\" : \"http://localhost:8080/products/7\"\n }\n }\n}"
}
}
""".trimIndent()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ class PostmanTaskTest : ApiSpecTaskTest() {
thenOutputFileForPublicResourceSpecificationFound()
}

@Test
fun `should run postman task with request parts`() {
title = "my custom title"
version = "2.0.0"
baseUrl = "https://example.com:8080/api"

givenBuildFileWithPostmanClosure()
givenResourceSnippetWithRequestParts()

whenPluginExecuted()

thenApiSpecTaskSuccessful()
thenOutputFileFound()
thenOutputFileForPublicResourceSpecificationNotFound()

with(outputFileContext()) {
then(read<String>("info.name")).isEqualTo(title)
then(read<String>("info.version")).isEqualTo(version)
then(read<String>("item[0].request.url.protocol")).isEqualTo("https")
then(read<String>("item[0].request.url.host")).isEqualTo("example.com")
then(read<String>("item[0].request.url.port")).isEqualTo("8080")
then(read<String>("item[0].request.url.path")).isEqualTo("/api/products/photo/:id")
then(read<String>("item[0].request.header[1].value")).isEqualTo("multipart/form-data")
then(read<String>("item[0].request.body.mode")).isEqualTo("formdata")
then(read<String>("item[0].request.body.formdata[0].key")).isEqualTo("photo")
then(read<String>("item[0].request.body.formdata[0].type")).isEqualTo("file")
then(read<List<String>>("item[0].request.body.formdata[0].src")).isEmpty()
}
}

private fun givenBuildFileWithPostmanClosure() {
buildFile.writeText(
baseBuildFile() + """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junitpioneer.jupiter.TempDirectory
import java.lang.Boolean.FALSE
import java.util.LinkedList

@ExtendWith(TempDirectory::class)
class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() {
Expand Down Expand Up @@ -73,6 +74,18 @@ class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() {
thenHeaderWithDefaultValuesContainedInOutput()
}

@Test
fun `should run openapi task with request parts`() {
givenBuildFileWithOpenApiClosureWithSingleServerString()
givenResourceSnippetWithRequestParts()

whenPluginExecuted()

thenApiSpecTaskSuccessful()
thenOutputFileFound()
thenRequestPartsContainedInOutput()
}

private fun thenSingleServerContainedInOutput() {
with(outputFileContext()) {
then(read<List<String>>("servers[*].url")).containsOnly("http://some.api")
Expand All @@ -96,6 +109,17 @@ class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() {
}
}

private fun thenRequestPartsContainedInOutput() {
with(outputFileContext()) {
then(read<LinkedHashMap<Any, Any>>("paths./products/photo/{id}.post.requestBody.content.multipart/form-data.schema")).isNotEmpty()
val multipartSchemaKey = read<LinkedHashMap<Any, Any>>("components.schemas").keys.toList()[0]
then(read<String>("components.schemas.${multipartSchemaKey}.required[0]")).isEqualTo("photo")
then(read<String>("components.schemas.${multipartSchemaKey}.type")).isEqualTo("object")
then(read<String>("components.schemas.${multipartSchemaKey}.properties.photo.type")).isEqualTo("string")
then(read<String>("components.schemas.${multipartSchemaKey}.properties.photo.format")).isEqualTo("binary")
}
}

fun givenBuildFileWithOpenApiClosureWithSingleServerString() {
givenBuildFileWithOpenApiClosure("server", """ 'http://some.api' """)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.maybeMinSizeArr
import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.maybePattern
import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.minInteger
import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.minLengthString
import com.epages.restdocs.apispec.jsonschema.schema.FileSchema
import com.epages.restdocs.apispec.model.Attributes
import com.epages.restdocs.apispec.model.FieldDescriptor
import com.fasterxml.jackson.databind.SerializationFeature
Expand Down Expand Up @@ -265,6 +266,7 @@ class JsonSchemaFromFieldDescriptorsGenerator {
EnumSchema.builder().possibleValues(this.attributes.enumValues).build()
)
).isSynthetic(true)
"file" -> FileSchema.builder()
else -> throw IllegalArgumentException("unknown field type $type")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.epages.restdocs.apispec.jsonschema.schema

import org.everit.json.schema.StringSchema
import org.everit.json.schema.internal.JSONPrinter

class FileSchema(
builder: BinarySchemaBuilder
) : StringSchema(builder) {

val format: String = builder.format

class BinarySchemaBuilder : StringSchema.Builder() {

internal var format: String = "binary"

override fun build(): FileSchema {
return FileSchema(this)
}

fun format(format : String) : BinarySchemaBuilder {
this.format = format
return this
}
}

companion object {
@JvmStatic
fun builder(): BinarySchemaBuilder {
return BinarySchemaBuilder()
}
}

override fun describeTo(writer: JSONPrinter) {
writer.`object`()
writer.ifPresent("title", super.getTitle())
writer.ifPresent("description", super.getDescription())
writer.ifPresent("id", super.getId())
writer.ifPresent("default", super.getDefaultValue())
writer.ifPresent("nullable", super.isNullable())
writer.ifPresent("readOnly", super.isReadOnly())
writer.ifPresent("writeOnly", super.isWriteOnly())
writer.key("type").value("string")
writer.key("format").value(format)
super.getUnprocessedProperties().forEach { (key: String?, `val`: Any?) ->
writer.key(
key
).value(`val`)
}
writer.endObject()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.epages.restdocs.apispec.jsonschema

import com.epages.restdocs.apispec.jsonschema.schema.FileSchema
import com.epages.restdocs.apispec.model.Attributes
import com.epages.restdocs.apispec.model.Constraint
import com.epages.restdocs.apispec.model.FieldDescriptor
Expand Down Expand Up @@ -453,6 +454,31 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest {
thenSchemaIsValid()
}

@Test
fun should_generate_schema_for_file_values() {
givenFieldDescriptorWithFile()

whenSchemaGenerated()

then(schema).isInstanceOf(ObjectSchema::class.java)
val fileSchemaSource = (schema as ObjectSchema).propertySchemas["some"]!!
val fileSchema = FileSchema.builder()
.title(fileSchemaSource.title)
.description(fileSchemaSource.description)
.schemaLocation(fileSchemaSource.schemaLocation)
.nullable(fileSchemaSource.isNullable)
.readOnly(fileSchemaSource.isReadOnly)
.writeOnly(fileSchemaSource.isWriteOnly)
.unprocessedProperties(fileSchemaSource.unprocessedProperties)
.build()

then(fileSchema).isInstanceOf(FileSchema::class.java)
fileSchema as FileSchema
then(fileSchema.format).isEqualTo("binary")

thenSchemaIsValid()
}

private fun thenSchemaIsValid() {

val report = JsonSchemaFactory.byDefault()
Expand Down Expand Up @@ -822,8 +848,18 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest {
)
}

private fun givenFieldDescriptorWithFile() {
fieldDescriptors = listOf(FieldDescriptor("some","", "file"))
}

private fun thenSchemaValidatesJson(json: String) {
schema!!.validate(if (json.startsWith("[")) JSONArray(json) else JSONObject(json))
schema!!.validate(
if (json.startsWith("[")) {
JSONArray(json)
} else {
JSONObject(json)
}
)
}

private fun thenSchemaDoesNotValidateJson(json: String) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.epages.restdocs.apispec.model

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import java.util.Comparator

data class ResourceModel(
val operationId: String,
Expand Down Expand Up @@ -40,6 +40,7 @@ data class RequestModel(
val queryParameters: List<ParameterDescriptor>,
val formParameters: List<ParameterDescriptor>,
val requestFields: List<FieldDescriptor>,
val requestParts: List<RequestPartFieldDescriptor>,
val example: String? = null,
val schema: Schema? = null
)
Expand Down Expand Up @@ -88,6 +89,47 @@ open class FieldDescriptor(
val attributes: Attributes = Attributes()
)

open class RequestPartFieldDescriptor(
val name: String,
val submittedFileName: String?,
val content: ByteArray,
val headers: RequestPartHeaderDescriptor,
val optional: Boolean = false,
val ignored: Boolean = false,
val attributes: Attributes = Attributes()
) {
companion object {
private val FILE_TYPES = listOf(
"application/msword",
"application/pdf",
"application/vnd.ms-excel",
"application/x-javascript",
"application/zip",
"image/jpeg",
"image/jpg",
"image/jpe",
"text/css",
"text/html",
"text/htm",
"text/plain",
"text/xml",
"text/xsl"
)
}
val type: String = when {
headers.contentType?.any { it in FILE_TYPES } == true -> "file"
else -> "string"
}
}

@JsonInclude(JsonInclude.Include.NON_NULL)
data class RequestPartHeaderDescriptor(
@JsonProperty("Content-Type")
val contentType: List<String>?,
@JsonProperty("Content-Length")
val contentLength: List<Long>?,
)

data class Attributes(
val validationConstraints: List<Constraint> = emptyList(),
val enumValues: List<Any> = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.epages.restdocs.apispec.model.HTTPMethod
import com.epages.restdocs.apispec.model.HeaderDescriptor
import com.epages.restdocs.apispec.model.Oauth2Configuration
import com.epages.restdocs.apispec.model.ParameterDescriptor
import com.epages.restdocs.apispec.model.RequestPartFieldDescriptor
import com.epages.restdocs.apispec.model.ResourceModel
import com.epages.restdocs.apispec.model.ResponseModel
import com.epages.restdocs.apispec.model.Schema
Expand Down Expand Up @@ -312,7 +313,12 @@ object OpenApi20Generator {
firstModelForPathAndMethod.request.schema
)
)
).nullIfEmpty()
).plus(
modelsWithSamePathAndMethod
.flatMap { it.request.requestParts }
.map { part2Parameters(it) }
)
.nullIfEmpty()
responses = responsesByStatusCode(
modelsWithSamePathAndMethod
)
Expand Down Expand Up @@ -478,6 +484,16 @@ object OpenApi20Generator {
}
}

private fun part2Parameters(partDescriptor: RequestPartFieldDescriptor): FormParameter {
return FormParameter().apply {
name = partDescriptor.name
description = partDescriptor.submittedFileName
required = partDescriptor.optional
type = partDescriptor.type
default = partDescriptor.content
}
}

private fun responseModel2Response(responseModel: ResponseModel): Response {
return Response().apply {
description = ""
Expand Down
Loading