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() - val maxItems = prop.findAnnotation() - val uniqueItems = prop.findAnnotation() +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()?.items ?: this.minItems + val maxItems = prop.findAnnotation()?.items ?: this.maxItems + val uniqueItems = prop.findAnnotation()?.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() - val maximum = prop.findAnnotation() - val multipleOf = prop.findAnnotation() - - var schema = this - - if (prop.returnType.isMarkedNullable) { - schema = schema.copy(nullable = true) - } - - return schema.copy( - minimum = minimum?.min?.toNumber(), - maximum = maximum?.max?.toNumber(), - exclusiveMinimum = minimum?.exclusive, - exclusiveMaximum = maximum?.exclusive, - multipleOf = multipleOf?.multiple?.toNumber(), - ) -} - -fun SimpleSchema.scanForConstraints(prop: KProperty1<*, *>): SimpleSchema { - val minLength = prop.findAnnotation()?.length ?: this.minLength - val maxLength = prop.findAnnotation()?.length ?: this.maxLength - val pattern = prop.findAnnotation()?.pattern ?: this.pattern - val format = prop.findAnnotation()?.format ?: this.format +fun FormattedSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): FormattedSchema { + val minimum = prop.findAnnotation()?.min?.toNumber() ?: this.minimum + val exclusiveMinimum = prop.findAnnotation()?.exclusive ?: this.exclusiveMinimum + val maximum = prop.findAnnotation()?.max?.toNumber() ?: this.maximum + val exclusiveMaximum = prop.findAnnotation()?.exclusive ?: this.exclusiveMaximum + val multipleOf = prop.findAnnotation()?.multiple?.toNumber() ?: this.multipleOf + val format = type.arguments.firstOrNull()?.type?.findAnnotation()?.format + ?: prop.findAnnotation()?.format ?: this.format val nullable = if (prop.returnType.isMarkedNullable) true else this.nullable - return copy( + return this.copy( + minimum = minimum, + maximum = maximum, + exclusiveMinimum = exclusiveMinimum, + exclusiveMaximum = exclusiveMaximum, + multipleOf = multipleOf, nullable = nullable, - minLength = minLength, - maxLength = maxLength, - pattern = pattern, format = format ) } -fun ObjectSchema.scanForConstraints(clazz: KClass<*>, prop: KProperty1<*, *>): ObjectSchema { +fun SimpleSchema.scanForConstraints(type: KType, prop: KProperty1<*, *>): SimpleSchema { + val minLength = prop.findAnnotation()?.length ?: this.minLength + val maxLength = prop.findAnnotation()?.length ?: this.maxLength + val pattern = prop.findAnnotation()?.pattern ?: this.pattern + val format = type.arguments.firstOrNull()?.type?.findAnnotation()?.format + ?: prop.findAnnotation()?.format ?: this.format + val nullable = if (prop.returnType.isMarkedNullable) true else this.nullable + + return this.copy( + minLength = minLength, + maxLength = maxLength, + pattern = pattern, + format = format, + nullable = nullable + ) +} + +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] 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( + summary = "formatted array item type", + description = "Cool stuff", + responseInfo = simpleOkResponse(), + requestInfo = RequestInfo("cool") + .copy(mediaTypes = listOf(ContentType.MultiPart.FormData.toString())) + ) + private fun simpleOkResponse() = ResponseInfo(HttpStatusCode.OK, "A successful endeavor") }