diff --git a/CHANGELOG.md b/CHANGELOG.md index 997f45604..cfaaed3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.1.0] - April 16th, 2021 + +### Changed + +- Completely redid the reflection system to improve flow, decrease errors ✨ + +### Added + +- Added ReDoc to the Playground to make manual testing more convenient + ## [0.0.7] - April 16th, 2021 ### Added diff --git a/gradle.properties b/gradle.properties index 9679f5405..aed8ca9c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Kompendium -project.version=0.0.7 +project.version=0.1.0 # Kotlin kotlin.code.style=official # Gradle diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d322e6117..972e51064 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,11 +9,12 @@ logback = "1.2.3" ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } ktor-jackson = { group = "io.ktor", name = "ktor-jackson", version.ref = "ktor" } +ktor-html-builder = { group = "io.ktor", name = "ktor-html-builder", version.ref = "ktor" } # Logging slf4j = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = "logback" } [bundles] -ktor = [ "ktor-server-core", "ktor-server-netty", "ktor-jackson" ] +ktor = [ "ktor-server-core", "ktor-server-netty", "ktor-jackson", "ktor-html-builder" ] logging = [ "slf4j", "logback-classic", "logback-core" ] 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 f218ec6ea..c845f9ea6 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt @@ -6,6 +6,7 @@ import io.ktor.routing.Route import io.ktor.routing.method import io.ktor.util.pipeline.PipelineInterceptor import kotlin.reflect.full.findAnnotation +import org.leafygreens.kompendium.Kontent.generateKontent import org.leafygreens.kompendium.annotations.KompendiumRequest import org.leafygreens.kompendium.annotations.KompendiumResponse import org.leafygreens.kompendium.models.meta.MethodInfo @@ -17,14 +18,11 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse +import org.leafygreens.kompendium.util.Helpers.COMPONENT_SLUG import org.leafygreens.kompendium.util.Helpers.calculatePath -import org.leafygreens.kompendium.util.Helpers.objectSchemaPair -import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent object Kompendium { - const val COMPONENT_SLUG = "#/components/schemas" - var openApiSpec = OpenApiSpec( info = OpenApiSpecInfo(), servers = mutableListOf(), @@ -34,7 +32,7 @@ object Kompendium { inline fun Route.notarizedGet( info: MethodInfo, noinline body: PipelineInterceptor - ): Route = generateComponentSchemas() { + ): Route = generateComponentSchemas() { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths[path]?.get = info.parseMethodInfo() @@ -44,7 +42,7 @@ object Kompendium { inline fun Route.notarizedPost( info: MethodInfo, noinline body: PipelineInterceptor - ): Route = generateComponentSchemas() { + ): Route = generateComponentSchemas() { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths[path]?.post = info.parseMethodInfo() @@ -54,7 +52,7 @@ object Kompendium { inline fun Route.notarizedPut( info: MethodInfo, noinline body: PipelineInterceptor, - ): Route = generateComponentSchemas() { + ): Route = generateComponentSchemas() { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths[path]?.put = info.parseMethodInfo() @@ -64,7 +62,7 @@ object Kompendium { inline fun Route.notarizedDelete( info: MethodInfo, noinline body: PipelineInterceptor - ): Route = generateComponentSchemas { + ): Route = generateComponentSchemas { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths[path]?.delete = info.parseMethodInfo() @@ -80,19 +78,21 @@ object Kompendium { requestBody = parseRequestAnnotation() ) - inline fun generateComponentSchemas( + inline fun generateComponentSchemas( block: () -> Route ): Route { - if (TResp::class != Unit::class) openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TResp::class)) - if (TReq::class != Unit::class) openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TReq::class)) + val responseKontent = generateKontent(TResp::class) + val requestKontent = generateKontent(TReq::class) + openApiSpec.components.schemas.putAll(responseKontent) + openApiSpec.components.schemas.putAll(requestKontent) return block.invoke() } inline fun parseRequestAnnotation(): OpenApiSpecRequest? = when (TReq::class) { Unit::class -> null - else -> { - val anny = TReq::class.findAnnotation() ?: error("My way or the highway bub") - OpenApiSpecRequest( + else -> when (val anny = TReq::class.findAnnotation()) { + null -> null + else -> OpenApiSpecRequest( description = anny.description, content = anny.mediaTypes.associate { val ref = OpenApiSpecReferenceObject("$COMPONENT_SLUG/${TReq::class.simpleName}") @@ -105,17 +105,19 @@ object Kompendium { inline fun parseResponseAnnotation(): Pair? = when (TResp::class) { Unit::class -> null - else -> { - val anny = TResp::class.findAnnotation() ?: error("My way or the highway bub") - val specResponse = OpenApiSpecResponse( - description = anny.description, - content = anny.mediaTypes.associate { - val ref = OpenApiSpecReferenceObject("$COMPONENT_SLUG/${TResp::class.simpleName}") - val mediaType = OpenApiSpecMediaType.Referenced(ref) - Pair(it, mediaType) - } - ) - Pair(anny.status, specResponse) + else -> when (val anny = TResp::class.findAnnotation()) { + null -> null + else -> { + val specResponse = OpenApiSpecResponse( + description = anny.description, + content = anny.mediaTypes.associate { + val ref = OpenApiSpecReferenceObject("$COMPONENT_SLUG/${TResp::class.simpleName}") + val mediaType = OpenApiSpecMediaType.Referenced(ref) + Pair(it, mediaType) + } + ) + Pair(anny.status, specResponse) + } } } diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt new file mode 100644 index 000000000..11eeb2d35 --- /dev/null +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kontent.kt @@ -0,0 +1,126 @@ +package org.leafygreens.kompendium + +import java.lang.reflect.ParameterizedType +import kotlin.reflect.KClass +import kotlin.reflect.KProperty +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.javaField +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.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.slf4j.LoggerFactory + +typealias SchemaMap = Map + +object Kontent { + + private val logger = LoggerFactory.getLogger(javaClass) + + fun generateKontent( + clazz: KClass<*>, + 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.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) + } + } + + private fun handleComplexType(clazz: KClass<*>, cache: SchemaMap): SchemaMap = + when (cache.containsKey(clazz.simpleName)) { + true -> { + logger.info("Cache already contains ${clazz.simpleName}, returning cache untouched") + cache + } + false -> { + logger.info("${clazz.simpleName} was not found in cache, generating now") + var newCache = cache + val fieldMap = clazz.memberProperties.associate { prop -> + logger.info("Analyzing $prop in class $clazz") + val field = prop.javaField?.type?.kotlin ?: error("Unable to parse field type from $prop") + logger.info("Detected field $field") + if (!newCache.containsKey(field.simpleName)) { + logger.info("Cache was missing ${field.simpleName}, adding now") + newCache = generateFieldKontent(prop, field, newCache) + } + val propSchema = ReferencedSchema(field.getReferenceSlug(prop)) + Pair(prop.name, propSchema) + } + logger.info("${clazz.simpleName} contains $fieldMap") + val schema = ObjectSchema(fieldMap) + logger.info("${clazz.simpleName} schema: $schema") + newCache.plus(clazz.simpleName!! to schema) + } + } + + private fun KClass<*>.getReferenceSlug(prop: KProperty<*>): String = when { + this.typeParameters.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, prop)}" + else -> "$COMPONENT_SLUG/${simpleName}" + } + + 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 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 schema = DictionarySchema(additionalProperties = valueReference) + val updatedCache = generateKontent(valClass, 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 + logger.info("Obtained collection class: $collectionClass") + val referenceName = genericNameAdapter(field, prop) + val valueReference = ReferencedSchema("$COMPONENT_SLUG/${collectionClass.simpleName}") + val schema = ArraySchema(items = valueReference) + val updatedCache = generateKontent(collectionClass, cache) + return updatedCache.plus(referenceName to schema) + } + +} diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt index e81c0503e..75474f0fe 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt @@ -1,22 +1,26 @@ package org.leafygreens.kompendium.models.oas // TODO Enum for type? -sealed class OpenApiSpecComponentSchema(open val type: String) +sealed class OpenApiSpecComponentSchema + +sealed class TypedSchema(open val type: String) : OpenApiSpecComponentSchema() + +data class ReferencedSchema(val `$ref`: String) : OpenApiSpecComponentSchema() data class ObjectSchema( val properties: Map -) : OpenApiSpecComponentSchema("object") +) : TypedSchema("object") data class DictionarySchema( val additionalProperties: OpenApiSpecComponentSchema -) : OpenApiSpecComponentSchema("object") +) : TypedSchema("object") data class EnumSchema( val `enum`: Set -) : OpenApiSpecComponentSchema("string") +) : TypedSchema("string") -data class SimpleSchema(override val type: String) : OpenApiSpecComponentSchema(type) +data class SimpleSchema(override val type: String) : TypedSchema(type) -data class FormatSchema(val format: String, override val type: String) : OpenApiSpecComponentSchema(type) +data class FormatSchema(val format: String, override val type: String) : TypedSchema(type) -data class ArraySchema(val items: OpenApiSpecComponentSchema) : OpenApiSpecComponentSchema("array") +data class ArraySchema(val items: OpenApiSpecComponentSchema) : TypedSchema("array") 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 fd9eb1cbf..d0671f0d9 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 @@ -12,6 +12,7 @@ import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.isSubclassOf import kotlin.reflect.full.memberProperties 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 @@ -26,6 +27,8 @@ object Helpers { private val logger = LoggerFactory.getLogger(javaClass) + const val COMPONENT_SLUG = "#/components/schemas" + @OptIn(InternalAPI::class) fun Route.calculatePath(tail: String = ""): String { logger.info("Building path for ${selector::class}") @@ -67,83 +70,27 @@ object Helpers { fun MutableMap.putPairIfAbsent(pair: Pair) = putIfAbsent(pair.first, pair.second) - // TODO Investigate a caching mechanism to reduce overhead... then just reference once created - fun objectSchemaPair(clazz: KClass<*>): Pair { - logger.info("Generating object schema for ${clazz.simpleName}") - val o = objectSchema(clazz) - return Pair(clazz.simpleName!!, o) - } - - private fun objectSchema(clazz: KClass<*>): ObjectSchema = - ObjectSchema(properties = clazz.memberProperties.associate { prop -> - logger.info("Analyzing $prop in class $clazz") - val field = prop.javaField?.type?.kotlin - val anny = prop.findAnnotation() - - if (anny != null) logger.info("Found field annotation: $anny") - - - val schema = when { - field?.isSubclassOf(Enum::class) == true -> { - logger.info("Detected that $prop is an enum") - val options = prop.javaField?.type?.enumConstants?.map { it.toString() }?.toSet() - ?: error("unable to parse enum $prop") - EnumSchema(options) - } - field?.isSubclassOf(Map::class) == true || field?.isSubclassOf(Map.Entry::class) == true -> { - logger.info("$prop is a Map, doing some crazy stuff") - mapFieldSchema(prop) - } - field?.isSubclassOf(Collection::class) == true -> { - logger.info("$prop is a List, building array schema") - listFieldSchema(prop) - } - else -> { - logger.info("$prop is not a list or map, going directly to schema detection") - fieldToSchema(field as KClass<*>) - } - } - - val name = anny?.let { - logger.info("Overriding property name with annotation $anny") - anny.name - } ?: prop.name - - Pair(name, schema) - }) - - private fun mapFieldSchema(prop: KProperty<*>): DictionarySchema { - val (keyType, valType) = (prop.javaField?.genericType as ParameterizedType) - .actualTypeArguments.slice(IntRange(0, 1)) - .map { it as Class<*> } - .map { it.kotlin } - .toPair() - if (keyType != String::class) error("Invalid Map $prop: OpenAPI dictionaries must have keys of type String") - return DictionarySchema(additionalProperties = fieldToSchema(valType)) - } - - private fun listFieldSchema(prop: KProperty<*>): ArraySchema { - val listType = ((prop.javaField?.genericType - as ParameterizedType).actualTypeArguments.first() - as Class<*>).kotlin - logger.info("Obtained List type, converting to schema $listType") - return ArraySchema(fieldToSchema(listType)) - } - - private fun fieldToSchema(field: KClass<*>): OpenApiSpecComponentSchema = when (field) { - Int::class -> FormatSchema("int32", "integer") - Long::class -> FormatSchema("int64", "integer") - Double::class -> FormatSchema("double", "number") - Float::class -> FormatSchema("float", "number") - String::class -> SimpleSchema("string") - Boolean::class -> SimpleSchema("boolean") - else -> objectSchema(field) - } - - private fun List.toPair(): Pair { + fun List.toPair(): Pair { if (this.size != 2) { throw IllegalArgumentException("List is not of length 2!") } 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 + */ + fun logged(functionName: String, entities: Map, block: () -> T): T { + entities.forEach { (name, entity) -> logger.info("Ahead of $functionName invocation, $name: $entity") } + val result = block.invoke() + logger.info("Result of $functionName invocation: $result") + return result + } } diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt index 9ed6946af..610fa269c 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt @@ -273,6 +273,22 @@ internal class KompendiumTest { } } + @Test + fun `Can notarize primitives`() { + withTestApplication({ + configModule() + openApiModule() + primitives() + }) { + // do + val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content + + // expect + val expected = TestData.getFileSnapshot("notarized_primitives.json").trim() + assertEquals(expected, json, "The received json spec should match the expected content") + } + } + private companion object { val testGetInfo = MethodInfo("Another get test", "testing more") val testPostInfo = MethodInfo("Test post endpoint", "Post your tests here!") @@ -381,6 +397,16 @@ internal class KompendiumTest { } } + private fun Application.primitives() { + routing { + route("/test") { + notarizedPut(testPutInfo) { + call.respondText { "heya" } + } + } + } + } + private fun Application.openApiModule() { routing { route("/openapi.json") { diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt new file mode 100644 index 000000000..a1e6c2597 --- /dev/null +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KontentTest.kt @@ -0,0 +1,169 @@ +package org.leafygreens.kompendium + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.leafygreens.kompendium.Kontent.generateKontent +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.TestInvalidMap +import org.leafygreens.kompendium.util.TestNestedModel +import org.leafygreens.kompendium.util.TestSimpleModel +import org.leafygreens.kompendium.util.TestSimpleWithEnumList +import org.leafygreens.kompendium.util.TestSimpleWithEnums +import org.leafygreens.kompendium.util.TestSimpleWithList +import org.leafygreens.kompendium.util.TestSimpleWithMap + +internal class KontentTest { + + @Test + fun `Unit returns empty map on generate`() { + // when + val clazz = Unit::class + + // do + val result = generateKontent(clazz) + + // expect + assertTrue { result.isEmpty() } + } + + @Test + fun `Primitive types return a single map result`() { + // when + val clazz = Long::class + + // do + val result = generateKontent(clazz) + + // 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) + + // expect + assertNotNull(result) + assertEquals(3, result.count()) + assertTrue { result.containsKey(clazz.simpleName) } + } + + @Test + fun `generation works for nested object types`() { + // when + val clazz = TestNestedModel::class + + // do + val result = generateKontent(clazz) + + // expect + assertNotNull(result) + assertEquals(4, result.count()) + assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestSimpleModel::class.simpleName) } + } + + @Test + fun `generation does not repeat for cached items`() { + // when + val clazz = TestNestedModel::class + val initialCache = generateKontent(clazz) + val claxx = TestSimpleModel::class + + // do + val result = generateKontent(claxx, initialCache) + + // expect TODO Spy to check invocation count? + assertNotNull(result) + assertEquals(4, result.count()) + assertTrue { result.containsKey(clazz.simpleName) } + assertTrue { result.containsKey(TestSimpleModel::class.simpleName) } + } + + @Test + fun `generation allows for enum fields`() { + // when + val clazz = TestSimpleWithEnums::class + + // do + val result = generateKontent(clazz) + + // expect + assertNotNull(result) + assertEquals(3, result.count()) + assertTrue { result.containsKey(clazz.simpleName) } + } + + @Test + fun `generation allows for map fields`() { + // when + val clazz = TestSimpleWithMap::class + + // do + val result = generateKontent(clazz) + + // expect + assertNotNull(result) + assertEquals(5, result.count()) + assertTrue { result.containsKey("Map-String-TestSimpleModel") } + assertTrue { result.containsKey(clazz.simpleName) } + + val os = result[clazz.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) } + } + + @Test + fun `generation allows for collection fields`() { + // when + val clazz = TestSimpleWithList::class + + // do + val result = generateKontent(clazz) + + // expect + assertNotNull(result) + assertEquals(6, result.count()) + assertTrue { result.containsKey("List-TestSimpleModel") } + assertTrue { result.containsKey(clazz.simpleName) } + } + + @Test + fun `generics as enums throws an exception`() { + // when + val clazz = TestSimpleWithEnumList::class + + // expect + assertFailsWith { generateKontent(clazz) } + } + +} diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt deleted file mode 100644 index c493cebce..000000000 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.leafygreens.kompendium.util - -import kotlin.test.Test -import kotlin.test.assertNotNull -import org.leafygreens.kompendium.util.Helpers.objectSchemaPair - -internal class HelpersTest { - - @Test - fun `can build an object schema from a complex object`() { - // when - val clazz = ComplexRequest::class - - // do - val result = objectSchemaPair(clazz) - - // expect - assertNotNull(result) - } - -} diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt index 537f88262..abe6c8697 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt @@ -4,6 +4,20 @@ import org.leafygreens.kompendium.annotations.KompendiumField import org.leafygreens.kompendium.annotations.KompendiumRequest import org.leafygreens.kompendium.annotations.KompendiumResponse +data class TestSimpleModel(val a: String, val b: Int) + +data class TestNestedModel(val inner: TestSimpleModel) + +data class TestSimpleWithEnums(val a: String, val b: SimpleEnum) + +data class TestSimpleWithMap(val a: String, val b: Map) + +data class TestSimpleWithList(val a: Boolean, val b: List) + +data class TestSimpleWithEnumList(val a: Double, val b: List) + +data class TestInvalidMap(val a: Map) + data class TestParams(val a: String, val aa: Int) data class TestNested(val nesty: String) diff --git a/kompendium-core/src/test/resources/complex_type.json b/kompendium-core/src/test/resources/complex_type.json index d87a10d27..54e9152ce 100644 --- a/kompendium-core/src/test/resources/complex_type.json +++ b/kompendium-core/src/test/resources/complex_type.json @@ -57,44 +57,62 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, "TestResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" } }, "type" : "object" }, + "SimpleEnum" : { + "enum" : [ "ONE", "TWO" ], + "type" : "string" + }, + "CrazyItem" : { + "properties" : { + "enumeration" : { + "$ref" : "#/components/schemas/SimpleEnum" + } + }, + "type" : "object" + }, + "Map-String-CrazyItem" : { + "additionalProperties" : { + "$ref" : "#/components/schemas/CrazyItem" + }, + "type" : "object" + }, + "NestedComplexItem" : { + "properties" : { + "alias" : { + "$ref" : "#/components/schemas/Map-String-CrazyItem" + }, + "name" : { + "$ref" : "#/components/schemas/String" + } + }, + "type" : "object" + }, + "List-NestedComplexItem" : { + "items" : { + "$ref" : "#/components/schemas/NestedComplexItem" + }, + "type" : "array" + }, "ComplexRequest" : { "properties" : { - "amazing_field" : { - "type" : "string" + "amazingField" : { + "$ref" : "#/components/schemas/String" }, "org" : { - "type" : "string" + "$ref" : "#/components/schemas/String" }, "tables" : { - "items" : { - "properties" : { - "alias" : { - "additionalProperties" : { - "properties" : { - "enumeration" : { - "enum" : [ "ONE", "TWO" ], - "type" : "string" - } - }, - "type" : "object" - }, - "type" : "object" - }, - "name" : { - "type" : "string" - } - }, - "type" : "object" - }, - "type" : "array" + "$ref" : "#/components/schemas/List-NestedComplexItem" } }, "type" : "object" diff --git a/kompendium-core/src/test/resources/notarized_get.json b/kompendium-core/src/test/resources/notarized_get.json index 88e06537f..06d49cd9a 100644 --- a/kompendium-core/src/test/resources/notarized_get.json +++ b/kompendium-core/src/test/resources/notarized_get.json @@ -46,10 +46,13 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, "TestResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" } }, "type" : "object" diff --git a/kompendium-core/src/test/resources/notarized_post.json b/kompendium-core/src/test/resources/notarized_post.json index 2974cf9c9..1d8f4767d 100644 --- a/kompendium-core/src/test/resources/notarized_post.json +++ b/kompendium-core/src/test/resources/notarized_post.json @@ -57,14 +57,42 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, + "Int" : { + "format" : "int32", + "type" : "integer" + }, "TestCreatedResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" }, "id" : { - "format" : "int32", - "type" : "integer" + "$ref" : "#/components/schemas/Int" + } + }, + "type" : "object" + }, + "Long" : { + "format" : "int64", + "type" : "integer" + }, + "List-Long" : { + "items" : { + "$ref" : "#/components/schemas/Long" + }, + "type" : "array" + }, + "Double" : { + "format" : "double", + "type" : "number" + }, + "TestNested" : { + "properties" : { + "nesty" : { + "$ref" : "#/components/schemas/String" } }, "type" : "object" @@ -72,23 +100,13 @@ "TestRequest" : { "properties" : { "aaa" : { - "items" : { - "format" : "int64", - "type" : "integer" - }, - "type" : "array" + "$ref" : "#/components/schemas/List-Long" }, "b" : { - "format" : "double", - "type" : "number" + "$ref" : "#/components/schemas/Double" }, - "field_name" : { - "properties" : { - "nesty" : { - "type" : "string" - } - }, - "type" : "object" + "fieldName" : { + "$ref" : "#/components/schemas/TestNested" } }, "type" : "object" diff --git a/kompendium-core/src/test/resources/notarized_primitives.json b/kompendium-core/src/test/resources/notarized_primitives.json new file mode 100644 index 000000000..90c36ea96 --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_primitives.json @@ -0,0 +1,49 @@ +{ + "openapi" : "3.0.3", + "info" : { + "title" : "Test API", + "version" : "1.33.7", + "description" : "An amazing, fully-ish \uD83D\uDE09 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/lg-backbone/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" : { + "put" : { + "tags" : [ ], + "summary" : "Test put endpoint", + "description" : "Put your tests here!", + "deprecated" : false + } + } + }, + "components" : { + "schemas" : { + "Boolean" : { + "type" : "boolean" + }, + "Int" : { + "format" : "int32", + "type" : "integer" + } + }, + "securitySchemes" : { } + }, + "security" : [ ], + "tags" : [ ] +} diff --git a/kompendium-core/src/test/resources/notarized_put.json b/kompendium-core/src/test/resources/notarized_put.json index adbb0f791..41f146b85 100644 --- a/kompendium-core/src/test/resources/notarized_put.json +++ b/kompendium-core/src/test/resources/notarized_put.json @@ -57,14 +57,42 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, + "Int" : { + "format" : "int32", + "type" : "integer" + }, "TestCreatedResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" }, "id" : { - "format" : "int32", - "type" : "integer" + "$ref" : "#/components/schemas/Int" + } + }, + "type" : "object" + }, + "Long" : { + "format" : "int64", + "type" : "integer" + }, + "List-Long" : { + "items" : { + "$ref" : "#/components/schemas/Long" + }, + "type" : "array" + }, + "Double" : { + "format" : "double", + "type" : "number" + }, + "TestNested" : { + "properties" : { + "nesty" : { + "$ref" : "#/components/schemas/String" } }, "type" : "object" @@ -72,23 +100,13 @@ "TestRequest" : { "properties" : { "aaa" : { - "items" : { - "format" : "int64", - "type" : "integer" - }, - "type" : "array" + "$ref" : "#/components/schemas/List-Long" }, "b" : { - "format" : "double", - "type" : "number" + "$ref" : "#/components/schemas/Double" }, - "field_name" : { - "properties" : { - "nesty" : { - "type" : "string" - } - }, - "type" : "object" + "fieldName" : { + "$ref" : "#/components/schemas/TestNested" } }, "type" : "object" diff --git a/kompendium-core/src/test/resources/path_parser.json b/kompendium-core/src/test/resources/path_parser.json index 4f85d5dd2..9fe9c541f 100644 --- a/kompendium-core/src/test/resources/path_parser.json +++ b/kompendium-core/src/test/resources/path_parser.json @@ -46,10 +46,13 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, "TestResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" } }, "type" : "object" diff --git a/kompendium-core/src/test/resources/root_route.json b/kompendium-core/src/test/resources/root_route.json index 1bdae1ff3..e00fd66a0 100644 --- a/kompendium-core/src/test/resources/root_route.json +++ b/kompendium-core/src/test/resources/root_route.json @@ -46,10 +46,13 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, "TestResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" } }, "type" : "object" diff --git a/kompendium-core/src/test/resources/trailing_slash.json b/kompendium-core/src/test/resources/trailing_slash.json index 7ad806e6c..b4b945939 100644 --- a/kompendium-core/src/test/resources/trailing_slash.json +++ b/kompendium-core/src/test/resources/trailing_slash.json @@ -46,10 +46,13 @@ }, "components" : { "schemas" : { + "String" : { + "type" : "string" + }, "TestResponse" : { "properties" : { "c" : { - "type" : "string" + "$ref" : "#/components/schemas/String" } }, "type" : "object" diff --git a/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt b/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt index e679b2937..5a6e82833 100644 --- a/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt +++ b/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt @@ -1,19 +1,31 @@ package org.leafygreens.kompendium.playground +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.SerializationFeature import io.ktor.application.Application import io.ktor.application.call import io.ktor.application.install import io.ktor.features.ContentNegotiation +import io.ktor.html.respondHtml import io.ktor.http.HttpStatusCode import io.ktor.jackson.jackson import io.ktor.response.respond import io.ktor.response.respondText +import io.ktor.routing.Routing import io.ktor.routing.get import io.ktor.routing.route import io.ktor.routing.routing import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import java.net.URI +import kotlinx.html.body +import kotlinx.html.head +import kotlinx.html.link +import kotlinx.html.meta +import kotlinx.html.script +import kotlinx.html.style +import kotlinx.html.title +import kotlinx.html.unsafe import org.leafygreens.kompendium.Kompendium.notarizedDelete import org.leafygreens.kompendium.Kompendium.notarizedGet import org.leafygreens.kompendium.Kompendium.notarizedPost @@ -76,9 +88,14 @@ object KompendiumTOC { fun Application.mainModule() { install(ContentNegotiation) { - jackson() + jackson { + enable(SerializationFeature.INDENT_OUTPUT) + setSerializationInclusion(JsonInclude.Include.NON_NULL) + } } routing { + openApi() + redoc() route("/test") { route("/{id}") { notarizedGet(testIdGetInfo) { @@ -100,37 +117,78 @@ fun Application.mainModule() { } } } - route("/openapi.json") { - get { - call.respond( - openApiSpec.copy( - info = OpenApiSpecInfo( - title = "Test API", - version = "1.33.7", - description = "An amazing, fully-ish 😉 generated API spec", - termsOfService = URI("https://example.com"), - contact = OpenApiSpecInfoContact( - name = "Homer Simpson", - email = "chunkylover53@aol.com", - url = URI("https://gph.is/1NPUDiM") - ), - license = OpenApiSpecInfoLicense( - name = "MIT", - url = URI("https://github.com/lg-backbone/kompendium/blob/main/LICENSE") - ) + } +} + +fun Routing.openApi() { + route("/openapi.json") { + get { + call.respond( + openApiSpec.copy( + info = OpenApiSpecInfo( + title = "Test API", + version = "1.33.7", + description = "An amazing, fully-ish 😉 generated API spec", + termsOfService = URI("https://example.com"), + contact = OpenApiSpecInfoContact( + name = "Homer Simpson", + email = "chunkylover53@aol.com", + url = URI("https://gph.is/1NPUDiM") ), - servers = mutableListOf( - OpenApiSpecServer( - url = URI("https://myawesomeapi.com"), - description = "Production instance of my API" - ), - OpenApiSpecServer( - url = URI("https://staging.myawesomeapi.com"), - description = "Where the fun stuff happens" - ) + license = OpenApiSpecInfoLicense( + name = "MIT", + url = URI("https://github.com/lg-backbone/kompendium/blob/main/LICENSE") + ) + ), + servers = mutableListOf( + OpenApiSpecServer( + url = URI("https://myawesomeapi.com"), + description = "Production instance of my API" + ), + OpenApiSpecServer( + url = URI("https://staging.myawesomeapi.com"), + description = "Where the fun stuff happens" ) ) ) + ) + } + } +} + +fun Routing.redoc() { + route("/docs") { + get { + call.respondHtml { + head { + title { + // TODO Make this load project title + +"Docs" + } + meta { + charset = "utf-8" + } + meta { + name = "viewport" + content = "width=device-width, initial-scale=1" + } + link { + href = "https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" + rel = "stylesheet" + } + style { + unsafe { + raw("body { margin: 0; padding: 0; }") + } + } + } + body { + // TODO Make this its own DSL class + unsafe { +"" } + script { + src = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js" + } + } } } }