Repository: bkbnio/kompendium
Pull request #205 (2022-02-23 09:48:18)

Draft implementation for Issue #204 (Swagger/Openapi: Multipart/form-data multiple file request support )
Here is the draft PR to support Format specification of an array items.

This fixes https://github.com/bkbnio/kompendium/issues/204

unredundant: This looks great! Think you just need to resolve a conflict in the constraint scanner, add a line to the CHANGELOG, then we'll merge this and I'll cut a release branch later today :) 
ydanneg: found an issue.. wait
ydanneg: would be great to cover scanner by tests.. but... let's wait for your big changes

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d28541152..7b0bd16c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 ## Unreleased
 - Fixed support Location classes located in other non-location classes
 - Fixed formatting of a custom SimpleSchema
+- Multipart form-data multiple file request support
 
 ### Added
 
diff --git a/kompendium-annotations/src/main/kotlin/io/bkbn/kompendium/annotations/constraint/Format.kt b/kompendium-annotations/src/main/kotlin/io/bkbn/kompendium/annotations/constraint/Format.kt
index 543b8169f..a8ab1ec09 100644
--- a/kompendium-annotations/src/main/kotlin/io/bkbn/kompendium/annotations/constraint/Format.kt
+++ b/kompendium-annotations/src/main/kotlin/io/bkbn/kompendium/annotations/constraint/Format.kt
@@ -1,5 +1,5 @@
 package io.bkbn.kompendium.annotations.constraint
 
 @Retention(AnnotationRetention.RUNTIME)
-@Target(AnnotationTarget.PROPERTY)
+@Target(AnnotationTarget.PROPERTY, AnnotationTarget.TYPE)
 annotation class Format(val format: String)
diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kontent.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kontent.kt
index 8a06fce49..5c28af427 100644
--- a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kontent.kt
+++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kontent.kt
@@ -1,16 +1,18 @@
 package io.bkbn.kompendium.core
 
-import io.bkbn.kompendium.core.metadata.SchemaMap
+import io.bkbn.kompendium.annotations.constraint.Format
 import io.bkbn.kompendium.core.handler.CollectionHandler
 import io.bkbn.kompendium.core.handler.EnumHandler
 import io.bkbn.kompendium.core.handler.MapHandler
 import io.bkbn.kompendium.core.handler.ObjectHandler
+import io.bkbn.kompendium.core.metadata.SchemaMap
 import io.bkbn.kompendium.core.util.Helpers.logged
 import io.bkbn.kompendium.oas.schema.FormattedSchema
 import io.bkbn.kompendium.oas.schema.SimpleSchema
 import kotlin.reflect.KClass
 import kotlin.reflect.KType
 import kotlin.reflect.full.createType
+import kotlin.reflect.full.findAnnotation
 import kotlin.reflect.full.isSubclassOf
 import kotlin.reflect.typeOf
 import org.slf4j.LoggerFactory
diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/constraint/ConstraintScanner.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/constraint/ConstraintScanner.kt
index a43c092f3..d0c31967a 100644
--- a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/constraint/ConstraintScanner.kt
+++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/constraint/ConstraintScanner.kt
@@ -24,32 +24,42 @@ import io.bkbn.kompendium.oas.schema.ReferencedSchema
 import io.bkbn.kompendium.oas.schema.SimpleSchema
 import kotlin.reflect.KClass
 import kotlin.reflect.KProperty1
+import kotlin.reflect.KType
 import kotlin.reflect.full.findAnnotation
 import kotlin.reflect.full.memberProperties
 import kotlin.reflect.full.primaryConstructor
 
-fun ComponentSchema.scanForConstraints(clazz: KClass<*>, prop: KProperty1<*, *>): ComponentSchema =
+fun ComponentSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): ComponentSchema =
   when (this) {
-    is AnyOfSchema -> AnyOfSchema(anyOf.map { it.scanForConstraints(clazz, prop) })
-    is ArraySchema -> scanForConstraints(prop)
+    is AnyOfSchema -> scanForConstraints(type, prop)
+    is ArraySchema -> scanForConstraints(type, prop)
     is DictionarySchema -> this // TODO Anything here?
     is EnumSchema -> scanForConstraints(prop)
-    is FormattedSchema -> scanForConstraints(prop)
+    is FormattedSchema -> scanForConstraints(type, prop)
     is FreeFormSchema -> this // todo anything here?
-    is ObjectSchema -> scanForConstraints(clazz, prop)
-    is SimpleSchema -> scanForConstraints(prop)
+    is ObjectSchema -> scanForConstraints(type, prop)
+    is SimpleSchema -> scanForConstraints(type, prop)
     is ReferencedSchema -> this // todo anything here?
   }
 
-fun ArraySchema.scanForConstraints(prop: KProperty1<*, *>): ArraySchema {
-  val minItems = prop.findAnnotation<MinItems>()
-  val maxItems = prop.findAnnotation<MaxItems>()
-  val uniqueItems = prop.findAnnotation<UniqueItems>()
+fun AnyOfSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): AnyOfSchema {
+  val anyOf = anyOf.map { it.scanForConstraints(type, prop) }
+  return this.copy(
+    anyOf = anyOf
+  )
+}
+
+fun ArraySchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): ArraySchema {
+  val minItems = prop.findAnnotation<MinItems>()?.items ?: this.minItems
+  val maxItems = prop.findAnnotation<MaxItems>()?.items ?: this.maxItems
+  val uniqueItems = prop.findAnnotation<UniqueItems>()?.let { true } ?: this.uniqueItems
+  val items = items.scanForConstraints(type, prop)
 
   return this.copy(
-    minItems = minItems?.items,
-    maxItems = maxItems?.items,
-    uniqueItems = uniqueItems?.let { true }
+    minItems = minItems,
+    maxItems = maxItems,
+    uniqueItems = uniqueItems,
+    items = items
   )
 }
 
@@ -61,43 +71,46 @@ fun EnumSchema.scanForConstraints(prop: KProperty1<*, *>): EnumSchema {
   return this
 }
 
-fun FormattedSchema.scanForConstraints(prop: KProperty1<*, *>): FormattedSchema {
-  val minimum = prop.findAnnotation<Minimum>()
-  val maximum = prop.findAnnotation<Maximum>()
-  val multipleOf = prop.findAnnotation<MultipleOf>()
-
-  var schema = this
-
-  if (prop.returnType.isMarkedNullable) {
-    schema = schema.copy(nullable = true)
-  }
+fun FormattedSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): FormattedSchema {
+  val minimum = prop.findAnnotation<Minimum>()?.min?.toNumber() ?: this.minimum
+  val exclusiveMinimum = prop.findAnnotation<Minimum>()?.exclusive ?: this.exclusiveMinimum
+  val maximum = prop.findAnnotation<Maximum>()?.max?.toNumber() ?: this.maximum
+  val exclusiveMaximum = prop.findAnnotation<Maximum>()?.exclusive ?: this.exclusiveMaximum
+  val multipleOf = prop.findAnnotation<MultipleOf>()?.multiple?.toNumber() ?: this.multipleOf
+  val format = type.arguments.firstOrNull()?.type?.findAnnotation<Format>()?.format
+    ?: prop.findAnnotation<Format>()?.format ?: this.format
+  val nullable = if (prop.returnType.isMarkedNullable) true else this.nullable
 
-  return schema.copy(
-    minimum = minimum?.min?.toNumber(),
-    maximum = maximum?.max?.toNumber(),
-    exclusiveMinimum = minimum?.exclusive,
-    exclusiveMaximum = maximum?.exclusive,
-    multipleOf = multipleOf?.multiple?.toNumber(),
+  return this.copy(
+    minimum = minimum,
+    maximum = maximum,
+    exclusiveMinimum = exclusiveMinimum,
+    exclusiveMaximum = exclusiveMaximum,
+    multipleOf = multipleOf,
+    nullable = nullable,
+    format = format
   )
 }
 
-fun SimpleSchema.scanForConstraints(prop: KProperty1<*, *>): SimpleSchema {
+fun SimpleSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): SimpleSchema {
   val minLength = prop.findAnnotation<MinLength>()?.length ?: this.minLength
   val maxLength = prop.findAnnotation<MaxLength>()?.length ?: this.maxLength
   val pattern = prop.findAnnotation<Pattern>()?.pattern ?: this.pattern
-  val format = prop.findAnnotation<Format>()?.format ?: this.format
+  val format = type.arguments.firstOrNull()?.type?.findAnnotation<Format>()?.format
+    ?: prop.findAnnotation<Format>()?.format ?: this.format
   val nullable = if (prop.returnType.isMarkedNullable) true else this.nullable
 
-  return copy(
-    nullable = nullable,
+  return this.copy(
     minLength = minLength,
     maxLength = maxLength,
     pattern = pattern,
-    format = format
+    format = format,
+    nullable = nullable
   )
 }
 
-fun ObjectSchema.scanForConstraints(clazz: KClass<*>, prop: KProperty1<*, *>): ObjectSchema {
+fun ObjectSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): ObjectSchema {
+  val clazz = type.classifier as KClass<*>
   var schema = this.adjustForRequiredParams(clazz)
   if (prop.returnType.isMarkedNullable) {
     schema = schema.copy(nullable = true)
diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/handler/ObjectHandler.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/handler/ObjectHandler.kt
index 9a5e85817..39fabea7d 100644
--- a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/handler/ObjectHandler.kt
+++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/handler/ObjectHandler.kt
@@ -168,7 +168,7 @@ object ObjectHandler : SchemaHandler {
     when (typeMap.containsKey(prop.returnType.classifier)) {
       true -> handleGenericProperty(typeMap, clazz, type, prop.returnType.classifier, cache)
       false -> handleStandardProperty(clazz, fieldClazz, prop, type, cache)
-    }.scanForConstraints(clazz, prop)
+    }.scanForConstraints(type, prop)
 
   /**
    * If a field has type parameters, leverage the constructed [TypeMap] to construct the [ComponentSchema]


Test patch:
diff --git a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt
index 6446bcfa2..0d3ebeded 100644
--- a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt
+++ b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt
@@ -17,6 +17,7 @@ import io.bkbn.kompendium.core.util.defaultParameter
 import io.bkbn.kompendium.core.util.exampleParams
 import io.bkbn.kompendium.core.util.exclusiveMinMax
 import io.bkbn.kompendium.core.util.formattedParam
+import io.bkbn.kompendium.core.util.formattedType
 import io.bkbn.kompendium.core.util.freeFormObject
 import io.bkbn.kompendium.core.util.genericPolymorphicResponse
 import io.bkbn.kompendium.core.util.genericPolymorphicResponseMultipleImpls
@@ -278,6 +279,9 @@ class KompendiumTest : DescribeSpec({
     it("Can set a minimum and maximum number of properties on a free-form type") {
       openApiTestAllSerializers("min_max_free_form.json") { minMaxFreeForm() }
     }
+    it("Can add a custom format to a collection type") {
+      openApiTestAllSerializers("formatted_array_item_type.json") { formattedType() }
+    }
   }
   describe("Formats") {
     it("Can set a format on a simple type schema") {
diff --git a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt
index e14fa204b..4071433e8 100644
--- a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt
+++ b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt
@@ -608,3 +608,13 @@ fun Application.exampleParams() {
     }
   }
 }
+
+fun Application.formattedType() {
+  routing {
+    route("/test/formatted_type") {
+      notarizedPost(TestResponseInfo.formattedArrayItemType) {
+        call.respond(HttpStatusCode.OK, TestResponse("hi"))
+      }
+    }
+  }
+}
diff --git a/kompendium-core/src/test/resources/formatted_array_item_type.json b/kompendium-core/src/test/resources/formatted_array_item_type.json
new file mode 100644
index 000000000..9e6f58c97
--- /dev/null
+++ b/kompendium-core/src/test/resources/formatted_array_item_type.json
@@ -0,0 +1,95 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "Test API",
+    "version": "1.33.7",
+    "description": "An amazing, fully-ish 😉 generated API spec",
+    "termsOfService": "https://example.com",
+    "contact": {
+      "name": "Homer Simpson",
+      "url": "https://gph.is/1NPUDiM",
+      "email": "chunkylover53@aol.com"
+    },
+    "license": {
+      "name": "MIT",
+      "url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
+    }
+  },
+  "servers": [
+    {
+      "url": "https://myawesomeapi.com",
+      "description": "Production instance of my API"
+    },
+    {
+      "url": "https://staging.myawesomeapi.com",
+      "description": "Where the fun stuff happens"
+    }
+  ],
+  "paths": {
+    "/test/formatted_type": {
+      "post": {
+        "tags": [],
+        "summary": "formatted array item type",
+        "description": "Cool stuff",
+        "parameters": [],
+        "requestBody": {
+          "description": "cool",
+          "content": {
+            "multipart/form-data": {
+              "schema": {
+                "$ref": "#/components/schemas/FormattedArrayItemType"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "A successful endeavor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/TestResponse"
+                }
+              }
+            }
+          }
+        },
+        "deprecated": false
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "FormattedArrayItemType": {
+        "properties": {
+          "a": {
+            "items": {
+              "type": "string",
+              "format": "binary"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "a"
+        ],
+        "type": "object"
+      },
+      "TestResponse": {
+        "properties": {
+          "c": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "c"
+        ],
+        "type": "object"
+      }
+    },
+    "securitySchemes": {}
+  },
+  "security": [],
+  "tags": []
+}
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt
index 14f30d89c..4edd3a3ca 100644
--- a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt
@@ -96,6 +96,10 @@ data class FormattedString(
   val a: String
 )
 
+data class FormattedArrayItemType(
+  val a: List<@Format("binary") String>
+)
+
 data class MinMaxString(
   @MinLength(42)
   @MaxLength(1337)
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt
index b6998923f..2249346f4 100644
--- a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt
@@ -11,6 +11,7 @@ import io.bkbn.kompendium.core.metadata.method.OptionsInfo
 import io.bkbn.kompendium.core.metadata.method.PatchInfo
 import io.bkbn.kompendium.core.metadata.method.PostInfo
 import io.bkbn.kompendium.core.metadata.method.PutInfo
+import io.ktor.http.ContentType
 import io.ktor.http.HttpStatusCode
 import kotlin.reflect.typeOf
 
@@ -300,5 +301,13 @@ object TestResponseInfo {
     )
   )
 
+  val formattedArrayItemType = PostInfo<Unit, FormattedArrayItemType, TestResponse>(
+    summary = "formatted array item type",
+    description = "Cool stuff",
+    responseInfo = simpleOkResponse(),
+    requestInfo = RequestInfo<FormattedArrayItemType>("cool")
+      .copy(mediaTypes = listOf(ContentType.MultiPart.FormData.toString()))
+  )
+
   private fun <T> simpleOkResponse() = ResponseInfo<T>(HttpStatusCode.OK, "A successful endeavor")
 }