Repository: bkbnio/kompendium (kotlin)

Issue:
Swagger/Openapi: Multipart/form-data multiple file request support
There is no way to generate swagger for multi file upload.
Single binary file works perfect if to use this class as response type for mutlipart/form-data content:
```kotlin
@Serializable
data class MultipartSingleFileContent(
  @Format("binary")
  val file: String,
)
```

But if I want to support multiple files, then i expect to Format my list items using type annotation:
```kotlin
@Serializable
data class MultipartMultiFileContent(
  val file: List<@Format("binary") String>
)
```
_@Format annotation is not targeted to types currently._. 



or even like that (but logic says NO!, because in this case annotation is actually applied to an array schema, not an item):
```
@Serializable
data class MultipartMultiFileContent(
  @Format("binary")
  val file: List<String>
)
```

Current behavior:
<img width="1409" alt="Screenshot 2022-02-23 at 09 31 11" src="https://user-images.githubusercontent.com/459303/155277360-99716fcb-74bb-47c2-a1b5-584f97780c57.png">

Expected behavior:
<img width="1421" alt="Screenshot 2022-02-23 at 09 31 41" src="https://user-images.githubusercontent.com/459303/155277393-04364df2-b627-4f57-a5f3-27f927f7f2cc.png">

Pull request description:
Here is the draft PR to support Format specification of an array items.

This fixes https://github.com/bkbnio/kompendium/issues/204

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d28541152a..7b0bd16c88 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 543b8169fc..a8ab1ec099 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 8a06fce492..5c28af4279 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 a43c092f35..d0c31967a7 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 9a5e858177..39fabea7d9 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 6446bcfa27..0d3ebeded5 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 e14fa204b4..4071433e84 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 0000000000..9e6f58c977
--- /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 14f30d89c1..4edd3a3ca6 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 b6998923f8..2249346f4b 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")
 }