diff --git a/CHANGELOG.md b/CHANGELOG.md index 99baf59f1..6b0d52cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.2.0] - April 16th, 2021 + +### Changed + +- Another re-haul to the reflection analysis +- Top level generics, enums, collections, and maps now supported 🙌 + ## [0.1.1] - April 16th, 2021 ### Added diff --git a/README.md b/README.md index 303e461a8..e73583487 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,11 @@ dependencies { ### Warning 🚨 Kompendium is still under active development ⚠️ There are a number of yet-to-be-implemented features, including -- Query and Path Parameters -- Tags -- Multiple Responses -- Security Schemas -- Top level enum classes (enums as fields in a response are a go ✅) +- Query and Path Parameters 🔍 +- Tags 🏷 +- Multiple Responses 📜 +- Security Schemas 🔏 +- Sealed Class / Polymorphic Support 😬 - Validation / Enforcement (❓👀❓) If you have a feature that is not listed here, please open an issue! diff --git a/gradle.properties b/gradle.properties index 6a1f69e13..83b6aa9f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # Kompendium -project.version=0.1.1 +project.version=0.2.0 # Kotlin kotlin.code.style=official # Gradle -org.gradle.vfs.watch=true -org.gradle.vfs.verbose=true +#org.gradle.vfs.watch=true +#org.gradle.vfs.verbose=true diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt index c845f9ea6..7701d92f2 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt @@ -81,8 +81,8 @@ object Kompendium { inline fun generateComponentSchemas( block: () -> Route ): Route { - val responseKontent = generateKontent(TResp::class) - val requestKontent = generateKontent(TReq::class) + val responseKontent = generateKontent() + val requestKontent = generateKontent() openApiSpec.components.schemas.putAll(responseKontent) openApiSpec.components.schemas.putAll(requestKontent) return block.invoke() diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt index 011e07e69..1c50d67a5 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt @@ -1,47 +1,58 @@ package org.leafygreens.kompendium -import java.lang.reflect.ParameterizedType import java.util.UUID import kotlin.reflect.KClass -import kotlin.reflect.KProperty +import kotlin.reflect.KType import kotlin.reflect.full.isSubclassOf import kotlin.reflect.full.memberProperties import kotlin.reflect.jvm.javaField +import kotlin.reflect.typeOf import org.leafygreens.kompendium.models.meta.SchemaMap import org.leafygreens.kompendium.models.oas.ArraySchema import org.leafygreens.kompendium.models.oas.DictionarySchema import org.leafygreens.kompendium.models.oas.EnumSchema import org.leafygreens.kompendium.models.oas.FormatSchema import org.leafygreens.kompendium.models.oas.ObjectSchema -import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema import org.leafygreens.kompendium.models.oas.ReferencedSchema import org.leafygreens.kompendium.models.oas.SimpleSchema +import org.leafygreens.kompendium.util.Helpers import org.leafygreens.kompendium.util.Helpers.COMPONENT_SLUG import org.leafygreens.kompendium.util.Helpers.genericNameAdapter -import org.leafygreens.kompendium.util.Helpers.logged -import org.leafygreens.kompendium.util.Helpers.toPair +import org.leafygreens.kompendium.util.Helpers.getReferenceSlug import org.slf4j.LoggerFactory object Kontent { private val logger = LoggerFactory.getLogger(javaClass) - fun generateKontent( - clazz: KClass<*>, + @OptIn(ExperimentalStdlibApi::class) + inline fun generateKontent( cache: SchemaMap = emptyMap() - ): SchemaMap = logged(object {}.javaClass.enclosingMethod.name, mapOf("cache" to cache)) { - when { - clazz == Unit::class -> cache - clazz == Int::class -> cache.plus(clazz.simpleName!! to FormatSchema("int32", "integer")) - clazz == Long::class -> cache.plus(clazz.simpleName!! to FormatSchema("int64", "integer")) - clazz == Double::class -> cache.plus(clazz.simpleName!! to FormatSchema("double", "number")) - clazz == Float::class -> cache.plus(clazz.simpleName!! to FormatSchema("float", "number")) - clazz == String::class -> cache.plus(clazz.simpleName!! to SimpleSchema("string")) - clazz == Boolean::class -> cache.plus(clazz.simpleName!! to SimpleSchema("boolean")) - clazz == UUID::class -> cache.plus(clazz.simpleName!! to FormatSchema("uuid", "string")) - clazz.isSubclassOf(Enum::class) -> error("Top level enums are currently not supported by Kompendium") - clazz.typeParameters.isNotEmpty() -> error("Top level generics are not supported by Kompendium") - else -> handleComplexType(clazz, cache) + ): SchemaMap { + val kontentType = typeOf() + return generateKTypeKontent(kontentType, cache) + } + + fun generateKTypeKontent( + type: KType, + cache: SchemaMap = emptyMap() + ): SchemaMap = Helpers.logged(object {}.javaClass.enclosingMethod.name, mapOf("cache" to cache)) { + logger.info("Parsing Kontent of $type") + when (val clazz = type.classifier as KClass<*>) { + Unit::class -> cache + Int::class -> cache.plus(clazz.simpleName!! to FormatSchema("int32", "integer")) + Long::class -> cache.plus(clazz.simpleName!! to FormatSchema("int64", "integer")) + Double::class -> cache.plus(clazz.simpleName!! to FormatSchema("double", "number")) + Float::class -> cache.plus(clazz.simpleName!! to FormatSchema("float", "number")) + String::class -> cache.plus(clazz.simpleName!! to SimpleSchema("string")) + Boolean::class -> cache.plus(clazz.simpleName!! to SimpleSchema("boolean")) + UUID::class -> cache.plus(clazz.simpleName!! to FormatSchema("uuid", "string")) + else -> when { + clazz.isSubclassOf(Collection::class) -> handleCollectionType(type, clazz, cache) + clazz.isSubclassOf(Enum::class) -> handleEnumType(clazz, cache) + clazz.isSubclassOf(Map::class) -> handleMapType(type, clazz, cache) + else -> handleComplexType(clazz, cache) + } } } @@ -60,7 +71,7 @@ object Kontent { logger.info("Detected field $field") if (!newCache.containsKey(field.simpleName)) { logger.info("Cache was missing ${field.simpleName}, adding now") - newCache = generateFieldKontent(prop, field, newCache) + newCache = generateKTypeKontent(prop.returnType, newCache) } val propSchema = ReferencedSchema(field.getReferenceSlug(prop)) Pair(prop.name, propSchema) @@ -72,56 +83,35 @@ object Kontent { } } - private fun KClass<*>.getReferenceSlug(prop: KProperty<*>): String = when { - this.typeParameters.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, prop)}" - else -> "$COMPONENT_SLUG/${simpleName}" + private fun handleEnumType(clazz: KClass<*>, cache: SchemaMap): SchemaMap { + val options = clazz.java.enumConstants.map { it.toString() }.toSet() + return cache.plus(clazz.simpleName!! to EnumSchema(options)) } - private fun generateFieldKontent( - prop: KProperty<*>, - field: KClass<*>, - cache: SchemaMap - ): SchemaMap = logged(object {}.javaClass.enclosingMethod.name, mapOf("cache" to cache)) { - when { - field.isSubclassOf(Enum::class) -> enumFieldHandler(prop, field, cache) - field.isSubclassOf(Map::class) -> mapFieldHandler(prop, field, cache) - field.isSubclassOf(Collection::class) -> collectionFieldHandler(prop, field, cache) - else -> generateKontent(field, cache) + private fun handleMapType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap { + logger.info("Map detected for $type, generating schema and appending to cache") + val (keyType, valType) = type.arguments.map { it.type } + logger.info("Obtained map types -> key: $keyType and value: $valType") + if (keyType?.classifier != String::class) { + error("Invalid Map $type: OpenAPI dictionaries must have keys of type String") } - } - - private fun enumFieldHandler(prop: KProperty<*>, field: KClass<*>, cache: SchemaMap): SchemaMap { - logger.info("Enum detected for $prop, gathering values") - val options = prop.javaField?.type?.enumConstants?.map { it.toString() }?.toSet() - ?: error("unable to parse enum $prop") - return cache.plus(field.simpleName!! to EnumSchema(options)) - } - - private fun mapFieldHandler(prop: KProperty<*>, field: KClass<*>, cache: SchemaMap): SchemaMap { - logger.info("Map detected for $prop, generating schema and appending to cache") - val (keyClass, valClass) = (prop.javaField?.genericType as ParameterizedType) - .actualTypeArguments.slice(IntRange(0, 1)) - .map { it as Class<*> } - .map { it.kotlin } - .toPair() - if (keyClass != String::class) error("Invalid Map $prop: OpenAPI dictionaries must have keys of type String") - val referenceName = genericNameAdapter(field, prop) - val valueReference = ReferencedSchema("$COMPONENT_SLUG/${valClass.simpleName}") + val valClassName = (valType?.classifier as KClass<*>).simpleName + val referenceName = genericNameAdapter(type, clazz) + val valueReference = ReferencedSchema("$COMPONENT_SLUG/$valClassName") val schema = DictionarySchema(additionalProperties = valueReference) - val updatedCache = generateKontent(valClass, cache) + val updatedCache = generateKTypeKontent(valType!!, cache) return updatedCache.plus(referenceName to schema) } - private fun collectionFieldHandler(prop: KProperty<*>, field: KClass<*>, cache: SchemaMap): SchemaMap { - logger.info("Collection detected for $prop, generating schema and appending to cache") - val collectionClass = ((prop.javaField?.genericType as ParameterizedType) - .actualTypeArguments.first() as Class<*>).kotlin + private fun handleCollectionType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap { + logger.info("Collection detected for $type, generating schema and appending to cache") + val collectionType = type.arguments.first().type!! + val collectionClass = collectionType.classifier as KClass<*> logger.info("Obtained collection class: $collectionClass") - val referenceName = genericNameAdapter(field, prop) - val valueReference = ReferencedSchema("$COMPONENT_SLUG/${collectionClass.simpleName}") + val referenceName = genericNameAdapter(type, clazz) + val valueReference = ReferencedSchema("${COMPONENT_SLUG}/${collectionClass.simpleName}") val schema = ArraySchema(items = valueReference) - val updatedCache = generateKontent(collectionClass, cache) + val updatedCache = generateKTypeKontent(collectionType, cache) return updatedCache.plus(referenceName to schema) } - } diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt index d0671f0d9..81e12cceb 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt @@ -8,19 +8,8 @@ import io.ktor.util.InternalAPI import java.lang.reflect.ParameterizedType import kotlin.reflect.KClass import kotlin.reflect.KProperty -import kotlin.reflect.full.findAnnotation -import kotlin.reflect.full.isSubclassOf -import kotlin.reflect.full.memberProperties +import kotlin.reflect.KType import kotlin.reflect.jvm.javaField -import org.leafygreens.kompendium.Kontent -import org.leafygreens.kompendium.annotations.KompendiumField -import org.leafygreens.kompendium.models.oas.ArraySchema -import org.leafygreens.kompendium.models.oas.DictionarySchema -import org.leafygreens.kompendium.models.oas.EnumSchema -import org.leafygreens.kompendium.models.oas.FormatSchema -import org.leafygreens.kompendium.models.oas.ObjectSchema -import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema -import org.leafygreens.kompendium.models.oas.SimpleSchema import org.slf4j.LoggerFactory object Helpers { @@ -29,6 +18,9 @@ object Helpers { const val COMPONENT_SLUG = "#/components/schemas" + /** + * TODO Explain this + */ @OptIn(InternalAPI::class) fun Route.calculatePath(tail: String = ""): String { logger.info("Building path for ${selector::class}") @@ -68,8 +60,20 @@ object Helpers { } } + /** + * Simple extension function that will take a [Pair] and place it (if absent) into a [MutableMap]. + * + * @receiver [MutableMap] + * @param pair to add to map + */ fun MutableMap.putPairIfAbsent(pair: Pair) = putIfAbsent(pair.first, pair.second) + /** + * Simple extension function that will convert a list with two items into a [Pair] + * @receiver [List] + * @return [Pair] + * @throws [IllegalArgumentException] when the list size is not exactly two + */ fun List.toPair(): Pair { if (this.size != 2) { throw IllegalArgumentException("List is not of length 2!") @@ -77,12 +81,6 @@ object Helpers { return Pair(this[0], this[1]) } - fun genericNameAdapter(field: KClass<*>, prop: KProperty<*>): String { - val typeArgs = (prop.javaField?.genericType as ParameterizedType).actualTypeArguments - val classNames = typeArgs.map { it as Class<*> }.map { it.kotlin }.map { it.simpleName } - return classNames.joinToString(separator = "-", prefix = "${field.simpleName}-") - } - /** * Higher order function that takes a map of names to objects and will log their state ahead of function invocation * along with the result of the function invocation @@ -93,4 +91,32 @@ object Helpers { logger.info("Result of $functionName invocation: $result") return result } + + /** + * Will build a reference slug that is useful for schema caching and references, particularly + * in the case of a class with type parameters + */ + fun KClass<*>.getReferenceSlug(prop: KProperty<*>): String = when { + this.typeParameters.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, prop)}" + else -> "$COMPONENT_SLUG/${simpleName}" + } + + /** + * Adapts a class with type parameters into a reference friendly string + */ + fun genericNameAdapter(field: KClass<*>, prop: KProperty<*>): String { + val typeArgs = (prop.javaField?.genericType as ParameterizedType).actualTypeArguments + val classNames = typeArgs.map { it as Class<*> }.map { it.kotlin }.map { it.simpleName } + return classNames.joinToString(separator = "-", prefix = "${field.simpleName}-") + } + + /** + * Adapts a class with type parameters into a reference friendly string + */ + fun genericNameAdapter(type: KType, clazz: KClass<*>): String { + val classNames = type.arguments + .map { it.type?.classifier as KClass<*> } + .map { it.simpleName } + return classNames.joinToString(separator = "-", prefix = "${clazz.simpleName}-") + } } diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt index be0e7e687..4a173e85b 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt @@ -7,9 +7,11 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.leafygreens.kompendium.Kontent.generateKontent +import org.leafygreens.kompendium.models.oas.DictionarySchema import org.leafygreens.kompendium.models.oas.FormatSchema import org.leafygreens.kompendium.models.oas.ObjectSchema import org.leafygreens.kompendium.models.oas.ReferencedSchema +import org.leafygreens.kompendium.util.ComplexRequest import org.leafygreens.kompendium.util.TestInvalidMap import org.leafygreens.kompendium.util.TestNestedModel import org.leafygreens.kompendium.util.TestSimpleModel @@ -19,15 +21,13 @@ import org.leafygreens.kompendium.util.TestSimpleWithList import org.leafygreens.kompendium.util.TestSimpleWithMap import org.leafygreens.kompendium.util.TestWithUUID +@ExperimentalStdlibApi internal class KontentTest { @Test fun `Unit returns empty map on generate`() { - // when - val clazz = Unit::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertTrue { result.isEmpty() } @@ -35,53 +35,34 @@ internal class KontentTest { @Test fun `Primitive types return a single map result`() { - // when - val clazz = Long::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertEquals(1, result.count(), "Should have a single result") assertEquals(FormatSchema("int64", "integer"), result["Long"]) } - @Test - fun `Throws an error when top level generics are detected`() { - // when - val womp = mapOf("asdf" to "fdsa", "2cool" to "4school") - val clazz = womp::class - - // expect - assertFailsWith { generateKontent(clazz) } - } - @Test fun `Objects reference their base types in the cache`() { - // when - val clazz = TestSimpleModel::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertNotNull(result) assertEquals(3, result.count()) - assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestSimpleModel::class.simpleName) } } @Test fun `generation works for nested object types`() { - // when - val clazz = TestNestedModel::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertNotNull(result) assertEquals(4, result.count()) - assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestNestedModel::class.simpleName) } assertTrue { result.containsKey(TestSimpleModel::class.simpleName) } } @@ -89,11 +70,10 @@ internal class KontentTest { fun `generation does not repeat for cached items`() { // when val clazz = TestNestedModel::class - val initialCache = generateKontent(clazz) - val claxx = TestSimpleModel::class + val initialCache = generateKontent() // do - val result = generateKontent(claxx, initialCache) + val result = generateKontent(initialCache) // expect TODO Spy to check invocation count? assertNotNull(result) @@ -104,85 +84,93 @@ internal class KontentTest { @Test fun `generation allows for enum fields`() { - // when - val clazz = TestSimpleWithEnums::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertNotNull(result) assertEquals(3, result.count()) - assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestSimpleWithEnums::class.simpleName) } } @Test fun `generation allows for map fields`() { - // when - val clazz = TestSimpleWithMap::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertNotNull(result) assertEquals(5, result.count()) assertTrue { result.containsKey("Map-String-TestSimpleModel") } - assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestSimpleWithMap::class.simpleName) } - val os = result[clazz.simpleName] as ObjectSchema + val os = result[TestSimpleWithMap::class.simpleName] as ObjectSchema val expectedRef = ReferencedSchema("#/components/schemas/Map-String-TestSimpleModel") assertEquals(expectedRef, os.properties["b"]) } @Test fun `map fields that are not string result in error`() { - // when - val clazz = TestInvalidMap::class - // expect - assertFailsWith { generateKontent(clazz) } + assertFailsWith { generateKontent() } } @Test fun `generation allows for collection fields`() { - // when - val clazz = TestSimpleWithList::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertNotNull(result) assertEquals(6, result.count()) assertTrue { result.containsKey("List-TestSimpleModel") } - assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestSimpleWithList::class.simpleName) } } @Test - fun `generics as enums throws an exception`() { - // when - val clazz = TestSimpleWithEnumList::class + fun `Can parse enum list as a field`() { + // do + val result = generateKontent() // expect - assertFailsWith { generateKontent(clazz) } + assertNotNull(result) } @Test fun `UUID schema support`() { - // when - val clazz = TestWithUUID::class - // do - val result = generateKontent(clazz) + val result = generateKontent() // expect assertNotNull(result) assertEquals(2, result.count()) assertTrue { result.containsKey(UUID::class.simpleName) } - assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestWithUUID::class.simpleName) } val expectedSchema = result[UUID::class.simpleName] as FormatSchema assertEquals(FormatSchema("uuid", "string"), expectedSchema) } + @Test + fun `Generate top level list response`() { + // do + val result = generateKontent>() + + // expect + assertNotNull(result) + } + + @Test + fun `Can handle a complex type`() { + // do + val result = generateKontent() + + // expect + assertNotNull(result) + assertEquals(7, result.count()) + assertTrue { result.containsKey("Map-String-CrazyItem") } + val ds = result["Map-String-CrazyItem"] as DictionarySchema + val rs = ds.additionalProperties as ReferencedSchema + assertEquals(ReferencedSchema("#/components/schemas/CrazyItem"), rs) + } + }