major reflection refactor (#15)

This commit is contained in:
Ryan Brink
2021-04-15 14:00:48 -04:00
committed by GitHub
parent 98a7a0a369
commit 8a033a6be8
20 changed files with 671 additions and 220 deletions

View File

@ -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

View File

@ -1,5 +1,5 @@
# Kompendium
project.version=0.0.7
project.version=0.1.0
# Kotlin
kotlin.code.style=official
# Gradle

View File

@ -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" ]

View File

@ -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 <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = generateComponentSchemas<TParam, Unit, TResp>() {
): Route = generateComponentSchemas<Unit, TResp>() {
val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.get = info.parseMethodInfo<Unit, TResp>()
@ -44,7 +42,7 @@ object Kompendium {
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = generateComponentSchemas<TParam, TReq, TResp>() {
): Route = generateComponentSchemas<TReq, TResp>() {
val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.post = info.parseMethodInfo<TReq, TResp>()
@ -54,7 +52,7 @@ object Kompendium {
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>,
): Route = generateComponentSchemas<TParam, TReq, TResp>() {
): Route = generateComponentSchemas<TReq, TResp>() {
val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.put = info.parseMethodInfo<TReq, TResp>()
@ -64,7 +62,7 @@ object Kompendium {
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = generateComponentSchemas<TParam, Unit, TResp> {
): Route = generateComponentSchemas<Unit, TResp> {
val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.delete = info.parseMethodInfo<Unit, TResp>()
@ -80,19 +78,21 @@ object Kompendium {
requestBody = parseRequestAnnotation<TReq>()
)
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> generateComponentSchemas(
inline fun <reified TReq : Any, reified TResp : Any> 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 <reified TReq> parseRequestAnnotation(): OpenApiSpecRequest? = when (TReq::class) {
Unit::class -> null
else -> {
val anny = TReq::class.findAnnotation<KompendiumRequest>() ?: error("My way or the highway bub")
OpenApiSpecRequest(
else -> when (val anny = TReq::class.findAnnotation<KompendiumRequest>()) {
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 <reified TResp> parseResponseAnnotation(): Pair<Int, OpenApiSpecResponse>? = when (TResp::class) {
Unit::class -> null
else -> {
val anny = TResp::class.findAnnotation<KompendiumResponse>() ?: 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<KompendiumResponse>()) {
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)
}
}
}

View File

@ -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<String, OpenApiSpecComponentSchema>
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)
}
}

View File

@ -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<String, OpenApiSpecComponentSchema>
) : OpenApiSpecComponentSchema("object")
) : TypedSchema("object")
data class DictionarySchema(
val additionalProperties: OpenApiSpecComponentSchema
) : OpenApiSpecComponentSchema("object")
) : TypedSchema("object")
data class EnumSchema(
val `enum`: Set<String>
) : 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")

View File

@ -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 <K, V> MutableMap<K, V>.putPairIfAbsent(pair: Pair<K, V>) = putIfAbsent(pair.first, pair.second)
// TODO Investigate a caching mechanism to reduce overhead... then just reference once created
fun objectSchemaPair(clazz: KClass<*>): Pair<String, ObjectSchema> {
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<KompendiumField>()
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 <T> List<T>.toPair(): Pair<T, T> {
fun <T> List<T>.toPair(): Pair<T, T> {
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 <T> logged(functionName: String, entities: Map<String, Any>, 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
}
}

View File

@ -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<Unit, Int, Boolean>(testPutInfo) {
call.respondText { "heya" }
}
}
}
}
private fun Application.openApiModule() {
routing {
route("/openapi.json") {

View File

@ -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<IllegalStateException> { 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<IllegalStateException> { 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<java.lang.IllegalStateException> { generateKontent(clazz) }
}
}

View File

@ -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)
}
}

View File

@ -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<String, TestSimpleModel>)
data class TestSimpleWithList(val a: Boolean, val b: List<TestSimpleModel>)
data class TestSimpleWithEnumList(val a: Double, val b: List<SimpleEnum>)
data class TestInvalidMap(val a: Map<Int, TestSimpleModel>)
data class TestParams(val a: String, val aa: Int)
data class TestNested(val nesty: String)

View File

@ -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"

View File

@ -46,10 +46,13 @@
},
"components" : {
"schemas" : {
"String" : {
"type" : "string"
},
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"

View File

@ -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"

View File

@ -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" : [ ]
}

View File

@ -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"

View File

@ -46,10 +46,13 @@
},
"components" : {
"schemas" : {
"String" : {
"type" : "string"
},
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"

View File

@ -46,10 +46,13 @@
},
"components" : {
"schemas" : {
"String" : {
"type" : "string"
},
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"

View File

@ -46,10 +46,13 @@
},
"components" : {
"schemas" : {
"String" : {
"type" : "string"
},
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"

View File

@ -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<ExampleParams, ExampleResponse>(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 { +"<redoc spec-url='/openapi.json'></redoc>" }
script {
src = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"
}
}
}
}
}