Merge branch 'main' of github.com:bkbnio/kompendium

This commit is contained in:
Ryan Brink
2023-09-04 11:34:12 -04:00
54 changed files with 1531 additions and 558 deletions

View File

@ -12,6 +12,26 @@
## Released
## [4.0.0-alpha] - September 3rd, 2023
### Added
- Support for `type` on sealed interfaces
- Ability to provide custom serializers
### Fixed
- Exception thrown when inheriting variable
- Notarized routes not discarded on test completion
- Data classes with property members breaks schema generation
- Security cannot be applied to individual path operations
- Serialization fails on generic response
- Parameter example descriptions not being applied
### Removed
- Out of the box support for Jackson and Gson (can still be implemented through custom schema configurators)
## [3.14.4] - June 5th, 2023
### Changed

View File

@ -48,8 +48,6 @@ dependencies {
testFixturesApi("io.ktor:ktor-server-core:$ktorVersion")
testFixturesApi("io.ktor:ktor-server-test-host:$ktorVersion")
testFixturesApi("io.ktor:ktor-serialization:$ktorVersion")
testFixturesApi("io.ktor:ktor-serialization-jackson:$ktorVersion")
testFixturesApi("io.ktor:ktor-serialization-gson:$ktorVersion")
testFixturesApi("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
testFixturesApi("io.ktor:ktor-server-content-negotiation:$ktorVersion")
testFixturesApi("io.ktor:ktor-server-auth:$ktorVersion")

View File

@ -10,6 +10,7 @@ class DeleteInfo private constructor(
override val summary: String,
override val description: String,
override val externalDocumentation: ExternalDocumentation?,
override val security: Map<String, List<String>>?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
@ -33,7 +34,8 @@ class DeleteInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security
)
}
}

View File

@ -12,7 +12,8 @@ class GetInfo private constructor(
override val externalDocumentation: ExternalDocumentation?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
override val parameters: List<Parameter>,
override val security: Map<String, List<String>>?
) : MethodInfo {
companion object {
@ -33,7 +34,8 @@ class GetInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security
)
}
}

View File

@ -12,7 +12,8 @@ class HeadInfo private constructor(
override val externalDocumentation: ExternalDocumentation?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
override val parameters: List<Parameter>,
override val security: Map<String, List<String>>?,
) : MethodInfo {
companion object {
@ -33,7 +34,8 @@ class HeadInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security,
)
}
}

View File

@ -9,6 +9,9 @@ sealed interface MethodInfo {
val tags: Set<String>
val summary: String
val description: String
val security: Map<String, List<String>>?
get() = null
val externalDocumentation: ExternalDocumentation?
get() = null
val operationId: String?
@ -28,6 +31,7 @@ sealed interface MethodInfo {
internal var tags: Set<String> = emptySet()
internal var parameters: List<Parameter> = emptyList()
internal var errors: MutableList<ResponseInfo> = mutableListOf()
internal var security: Map<String, List<String>>? = null
fun response(init: ResponseInfo.Builder.() -> Unit) = apply {
val builder = ResponseInfo.Builder()
@ -59,6 +63,8 @@ sealed interface MethodInfo {
fun parameters(vararg parameters: Parameter) = apply { this.parameters = parameters.toList() }
fun security(security: Map<String, List<String>>) = apply { this.security = security }
abstract fun build(): T
}
}

View File

@ -12,7 +12,8 @@ class OptionsInfo private constructor(
override val externalDocumentation: ExternalDocumentation?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
override val parameters: List<Parameter>,
override val security: Map<String, List<String>>?,
) : MethodInfo {
companion object {
@ -33,7 +34,8 @@ class OptionsInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security,
)
}
}

View File

@ -13,7 +13,8 @@ class PatchInfo private constructor(
override val externalDocumentation: ExternalDocumentation?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
override val parameters: List<Parameter>,
override val security: Map<String, List<String>>?,
) : MethodInfoWithRequest {
companion object {
@ -35,7 +36,8 @@ class PatchInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security,
)
}
}

View File

@ -13,7 +13,8 @@ class PostInfo private constructor(
override val externalDocumentation: ExternalDocumentation?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
override val parameters: List<Parameter>,
override val security: Map<String, List<String>>?,
) : MethodInfoWithRequest {
companion object {
@ -35,7 +36,8 @@ class PostInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security,
)
}
}

View File

@ -13,7 +13,8 @@ class PutInfo private constructor(
override val externalDocumentation: ExternalDocumentation?,
override val operationId: String?,
override val deprecated: Boolean,
override val parameters: List<Parameter>
override val parameters: List<Parameter>,
override val security: Map<String, List<String>>?,
) : MethodInfoWithRequest {
companion object {
@ -35,7 +36,8 @@ class PutInfo private constructor(
externalDocumentation = externalDocumentation,
operationId = operationId,
deprecated = deprecated,
parameters = parameters
parameters = parameters,
security = security,
)
}
}

View File

@ -49,8 +49,8 @@ class RequestInfo private constructor(
fun description(s: String) = apply { this.description = s }
fun examples(vararg e: Pair<String, Any>) = apply {
this.examples = e.toMap().mapValues { (_, v) -> MediaType.Example(v) }
fun examples(vararg e: Pair<String, MediaType.Example>) = apply {
this.examples = e.toMap()
}
fun mediaTypes(vararg m: String) = apply {

View File

@ -57,8 +57,8 @@ class ResponseInfo private constructor(
fun description(s: String) = apply { this.description = s }
fun examples(vararg e: Pair<String, Any>) = apply {
this.examples = e.toMap().mapValues { (_, v) -> MediaType.Example(v) }
fun examples(vararg e: Pair<String, MediaType.Example>) = apply {
this.examples = e.toMap()
}
fun mediaTypes(vararg m: String) = apply {

View File

@ -1,16 +1,15 @@
package io.bkbn.kompendium.core.plugin
import io.bkbn.kompendium.core.attribute.KompendiumAttributes
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import io.bkbn.kompendium.oas.OpenApiSpec
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.application
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
@ -19,25 +18,26 @@ import kotlin.reflect.KType
object NotarizedApplication {
class Config {
lateinit var spec: OpenApiSpec
var openApiJson: Routing.() -> Unit = {
route("/openapi.json") {
lateinit var spec: () -> OpenApiSpec
var specRoute: (OpenApiSpec, Routing) -> Unit = { spec, routing ->
routing.route("/openapi.json") {
get {
call.respond(HttpStatusCode.OK, this@route.application.attributes[KompendiumAttributes.openApiSpec])
call.respond(spec)
}
}
}
var customTypes: Map<KType, JsonSchema> = emptyMap()
var schemaConfigurator: SchemaConfigurator = SchemaConfigurator.Default()
var schemaConfigurator: SchemaConfigurator = KotlinXSchemaConfigurator()
}
operator fun invoke() = createApplicationPlugin(
name = "NotarizedApplication",
createConfiguration = ::Config
) {
val spec = pluginConfig.spec
val spec = pluginConfig.spec()
val routing = application.routing {}
pluginConfig.openApiJson(routing)
this@createApplicationPlugin.pluginConfig.specRoute(spec, routing)
// pluginConfig.openApiJson(routing)
pluginConfig.customTypes.forEach { (type, schema) ->
spec.components.schemas[type.getSimpleSlug()] = schema
}

View File

@ -118,10 +118,7 @@ object Helpers {
operationId = this.operationId,
deprecated = this.deprecated,
parameters = this.parameters,
security = config.security
?.map { (k, v) -> k to v }
?.map { listOf(it).toMap() }
?.toMutableList(),
security = this.createCombinedSecurityContext(config),
requestBody = when (this) {
is MethodInfoWithRequest -> this.request?.let { reqInfo ->
Request(
@ -150,6 +147,25 @@ object Helpers {
).plus(this.errors.toResponseMap())
)
private fun MethodInfo.createCombinedSecurityContext(config: SpecConfig): MutableList<Map<String, List<String>>>? {
val configSecurity = config.security
?.map { (k, v) -> k to v }
?.map { listOf(it).toMap() }
?.toMutableList()
val methodSecurity = this.security
?.map { (k, v) -> k to v }
?.map { listOf(it).toMap() }
?.toMutableList()
return when {
configSecurity == null && methodSecurity == null -> null
configSecurity == null -> methodSecurity
methodSecurity == null -> configSecurity
else -> configSecurity.plus(methodSecurity).toMutableList()
}
}
private fun List<ResponseInfo>.toResponseMap(): Map<Int, Response> = associate { error ->
error.responseCode.value to Response(
description = error.description,

View File

@ -6,17 +6,20 @@ import io.bkbn.kompendium.core.util.arrayConstraints
import io.bkbn.kompendium.core.util.complexRequest
import io.bkbn.kompendium.core.util.customAuthConfig
import io.bkbn.kompendium.core.util.customFieldNameResponse
import io.bkbn.kompendium.core.util.customScopesOnSiblingPathOperations
import io.bkbn.kompendium.core.util.dateTimeString
import io.bkbn.kompendium.core.util.defaultAuthConfig
import io.bkbn.kompendium.core.util.defaultField
import io.bkbn.kompendium.core.util.defaultParameter
import io.bkbn.kompendium.core.util.doubleConstraints
import io.bkbn.kompendium.core.util.enrichedGenericResponse
import io.bkbn.kompendium.core.util.enrichedComplexGenericType
import io.bkbn.kompendium.core.util.enrichedGenericResponse
import io.bkbn.kompendium.core.util.enrichedNestedCollection
import io.bkbn.kompendium.core.util.enrichedSimpleRequest
import io.bkbn.kompendium.core.util.enrichedSimpleResponse
import io.bkbn.kompendium.core.util.exampleParams
import io.bkbn.kompendium.core.util.exampleSummaryAndDescription
import io.bkbn.kompendium.core.util.fieldOutsideConstructor
import io.bkbn.kompendium.core.util.genericException
import io.bkbn.kompendium.core.util.genericPolymorphicResponse
import io.bkbn.kompendium.core.util.genericPolymorphicResponseMultipleImpls
@ -44,7 +47,10 @@ import io.bkbn.kompendium.core.util.nullableEnumField
import io.bkbn.kompendium.core.util.nullableField
import io.bkbn.kompendium.core.util.nullableNestedObject
import io.bkbn.kompendium.core.util.nullableReference
import io.bkbn.kompendium.core.util.optionalReqExample
import io.bkbn.kompendium.core.util.overrideMediaTypes
import io.bkbn.kompendium.core.util.overrideSealedTypeIdentifier
import io.bkbn.kompendium.core.util.paramWrapper
import io.bkbn.kompendium.core.util.polymorphicCollectionResponse
import io.bkbn.kompendium.core.util.polymorphicException
import io.bkbn.kompendium.core.util.polymorphicMapResponse
@ -52,7 +58,6 @@ import io.bkbn.kompendium.core.util.polymorphicResponse
import io.bkbn.kompendium.core.util.postNoReqBody
import io.bkbn.kompendium.core.util.primitives
import io.bkbn.kompendium.core.util.reqRespExamples
import io.bkbn.kompendium.core.util.optionalReqExample
import io.bkbn.kompendium.core.util.requiredParams
import io.bkbn.kompendium.core.util.responseHeaders
import io.bkbn.kompendium.core.util.returnsList
@ -66,9 +71,9 @@ import io.bkbn.kompendium.core.util.singleException
import io.bkbn.kompendium.core.util.stringConstraints
import io.bkbn.kompendium.core.util.stringContentEncodingConstraints
import io.bkbn.kompendium.core.util.stringPatternConstraints
import io.bkbn.kompendium.core.util.subtypeNotCompleteSetOfParentProperties
import io.bkbn.kompendium.core.util.topLevelNullable
import io.bkbn.kompendium.core.util.trailingSlash
import io.bkbn.kompendium.core.util.paramWrapper
import io.bkbn.kompendium.core.util.unbackedFieldsResponse
import io.bkbn.kompendium.core.util.withOperationId
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
@ -78,6 +83,7 @@ import io.bkbn.kompendium.oas.security.ApiKeyAuth
import io.bkbn.kompendium.oas.security.BasicAuth
import io.bkbn.kompendium.oas.security.BearerAuth
import io.bkbn.kompendium.oas.security.OAuth
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.should
@ -85,6 +91,8 @@ import io.kotest.matchers.string.startWith
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.http.HttpMethod
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.OAuthServerSettings
@ -92,6 +100,11 @@ import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.basic
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.oauth
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.URI
import java.time.Instant
import kotlin.reflect.typeOf
@ -182,6 +195,9 @@ class KompendiumTest : DescribeSpec({
it("Can generate example optional request body") {
openApiTestAllSerializers("T0069__example_optional_req.json") { optionalReqExample() }
}
it("Can generate example summary and description") {
openApiTestAllSerializers("T0075__example_summary_and_description.json") { exampleSummaryAndDescription() }
}
}
describe("Defaults") {
it("Can generate a default parameter value") {
@ -235,6 +251,13 @@ class KompendiumTest : DescribeSpec({
it("Can handle a really gnarly generic example") {
openApiTestAllSerializers("T0043__gnarly_generic_example.json") { gnarlyGenericResponse() }
}
it("Can override the type name for a sealed interface implementation") {
openApiTestAllSerializers("T0070__sealed_interface_type_name_override.json") { overrideSealedTypeIdentifier() }
}
it("Can serialize an object where the subtype is not a complete set of parent properties") {
openApiTestAllSerializers("T0071__subtype_not_complete_set_of_parent_properties.json") {
subtypeNotCompleteSetOfParentProperties()
}
}
describe("Custom Serializable Reader tests") {
it("Can support ignoring fields") {
@ -316,6 +339,39 @@ class KompendiumTest : DescribeSpec({
}
) { notarizedGet() }
}
it("Can apply a custom serialization strategy to the openapi document") {
val customJsonEncoder = Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = false
}
openApiTestAllSerializers(
snapshotName = "T0072__custom_serialization_strategy.json",
notarizedApplicationConfigOverrides = {
specRoute = { spec, routing ->
routing {
route("/openapi.json") {
get {
call.response.headers.append("Content-Type", "application/json")
call.respondText { customJsonEncoder.encodeToString(spec) }
}
}
}
}
},
contentNegotiation = {
json(
Json {
encodeDefaults = true
explicitNulls = true
}
)
}
) { notarizedGet() }
}
it("Can serialize a data class with a field outside of the constructor") {
openApiTestAllSerializers("T0073__data_class_with_field_outside_constructor.json") { fieldOutsideConstructor() }
}
}
describe("Error Handling") {
it("Throws a clear exception when an unidentified type is encountered") {
@ -447,6 +503,50 @@ class KompendiumTest : DescribeSpec({
}
) { multipleAuthStrategies() }
}
it("Can provide different scopes on path operations in the same route") {
openApiTestAllSerializers(
snapshotName = "T0074__auth_on_specific_path_operation.json",
applicationSetup = {
install(Authentication) {
oauth("auth-oauth-google") {
urlProvider = { "http://localhost:8080/callback" }
providerLookup = {
OAuthServerSettings.OAuth2ServerSettings(
name = "google",
authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
accessTokenUrl = "https://accounts.google.com/o/oauth2/token",
requestMethod = HttpMethod.Post,
clientId = "DUMMY_VAL",
clientSecret = "DUMMY_VAL",
defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile"),
extraTokenParameters = listOf("access_type" to "offline")
)
}
client = HttpClient(CIO)
}
}
},
specOverrides = {
this.copy(
components = Components(
securitySchemes = mutableMapOf(
"auth-oauth-google" to OAuth(
flows = OAuth.Flows(
implicit = OAuth.Flows.Implicit(
authorizationUrl = "https://accounts.google.com/o/oauth2/auth",
scopes = mapOf(
"write:pets" to "modify pets in your account",
"read:pets" to "read your pets"
)
)
)
)
)
)
)
}
) { customScopesOnSiblingPathOperations() }
}
}
describe("Enrichment") {
it("Can enrich a simple request") {
@ -487,4 +587,5 @@ class KompendiumTest : DescribeSpec({
openApiTestAllSerializers("T0064__array_constraints.json") { arrayConstraints() }
}
}
}
})

View File

@ -2,6 +2,7 @@ package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.util.TestModules.defaultPathDescription
import io.bkbn.kompendium.core.util.TestModules.defaultPathSummary
@ -11,6 +12,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.routing.Routing
import io.ktor.server.routing.post
import io.ktor.server.routing.route
fun Routing.defaultAuthConfig() {
@ -42,6 +44,43 @@ fun Routing.customAuthConfig() {
}
}
fun Routing.customScopesOnSiblingPathOperations() {
authenticate("auth-oauth-google") {
route(rootPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary(defaultPathSummary)
description(defaultPathDescription)
response {
description(defaultResponseDescription)
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
}
security = mapOf(
"auth-oauth-google" to listOf("read:pets")
)
}
post = PostInfo.builder {
summary(defaultPathSummary)
description(defaultPathDescription)
response {
description(defaultResponseDescription)
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
}
request {
description(defaultResponseDescription)
requestType<TestResponse>()
}
security = mapOf(
"auth-oauth-google" to listOf("write:pets")
)
}
}
}
}
}
fun Routing.multipleAuthStrategies() {
authenticate("jwt", "api-key") {
route(rootPath) {

View File

@ -11,6 +11,7 @@ import io.bkbn.kompendium.core.util.TestModules.defaultRequestDescription
import io.bkbn.kompendium.core.util.TestModules.defaultResponseDescription
import io.bkbn.kompendium.core.util.TestModules.rootPath
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.MediaType
import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
@ -27,7 +28,7 @@ fun Routing.reqRespExamples() {
description(defaultRequestDescription)
requestType<TestRequest>()
examples(
"Testerina" to TestRequest(TestNested("asdf"), 1.5, emptyList())
"Testerina" to MediaType.Example(TestRequest(TestNested("asdf"), 1.5, emptyList()))
)
}
response {
@ -35,7 +36,7 @@ fun Routing.reqRespExamples() {
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
examples(
"Testerino" to TestResponse("Heya")
"Testerino" to MediaType.Example(TestResponse("Heya"))
)
}
}
@ -50,7 +51,7 @@ fun Routing.exampleParams() = basicGetGenerator<TestResponse>(
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING,
examples = mapOf(
"foo" to Parameter.Example("testing")
"foo" to MediaType.Example("testing")
)
)
)
@ -66,7 +67,7 @@ fun Routing.optionalReqExample() {
description(defaultRequestDescription)
requestType<TestRequest>()
examples(
"Testerina" to TestRequest(TestNested("asdf"), 1.5, emptyList())
"Testerina" to MediaType.Example(TestRequest(TestNested("asdf"), 1.5, emptyList()))
)
required(false)
}
@ -75,7 +76,37 @@ fun Routing.optionalReqExample() {
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
examples(
"Testerino" to TestResponse("Heya")
"Testerino" to MediaType.Example(TestResponse("Heya"))
)
}
}
}
}
}
fun Routing.exampleSummaryAndDescription() {
route(rootPath) {
install(NotarizedRoute()) {
post = PostInfo.builder {
summary("This is a summary")
description("This is a description")
request {
description("This is a request description")
requestType<TestRequest>()
examples(
"Testerina" to MediaType.Example(
TestRequest(TestNested("asdf"), 1.5, emptyList()),
"summary",
"description"
)
)
}
response {
description("This is a response description")
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
examples(
"Testerino" to MediaType.Example(TestResponse("Heya"), "summary", "description")
)
}
}

View File

@ -1,6 +1,7 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.SomethingSimilar
import io.bkbn.kompendium.core.fixtures.TestCreatedResponse
import io.bkbn.kompendium.core.fixtures.TestRequest
import io.bkbn.kompendium.core.fixtures.TestResponse
@ -349,3 +350,23 @@ fun Routing.postNoReqBody() {
}
}
}
fun Routing.fieldOutsideConstructor() {
route("/field_outside_constructor") {
install(NotarizedRoute()) {
post = PostInfo.builder {
summary(defaultPathSummary)
description(defaultPathDescription)
request {
requestType<SomethingSimilar>()
description("A cool request")
}
response {
responseType<TestResponse>()
description("Cool response")
responseCode(HttpStatusCode.Created)
}
}
}
}
}

View File

@ -1,11 +1,13 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.Barzo
import io.bkbn.kompendium.core.fixtures.ChillaxificationMaximization
import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.Flibbity
import io.bkbn.kompendium.core.fixtures.FlibbityGibbit
import io.bkbn.kompendium.core.fixtures.Foosy
import io.bkbn.kompendium.core.fixtures.Gibbity
import io.bkbn.kompendium.core.fixtures.Gizmo
import io.bkbn.kompendium.core.fixtures.MultiNestedGenerics
import io.bkbn.kompendium.core.fixtures.Page
import io.ktor.server.routing.Routing
@ -20,3 +22,5 @@ fun Routing.genericPolymorphicResponse() = basicGetGenerator<Flibbity<Double>>()
fun Routing.genericPolymorphicResponseMultipleImpls() = basicGetGenerator<Flibbity<FlibbityGibbit>>()
fun Routing.nestedGenericCollection() = basicGetGenerator<Page<Int>>()
fun Routing.nestedGenericMultipleParamsCollection() = basicGetGenerator<MultiNestedGenerics<String, ComplexRequest>>()
fun Routing.overrideSealedTypeIdentifier() = basicGetGenerator<ChillaxificationMaximization>()
fun Routing.subtypeNotCompleteSetOfParentProperties() = basicGetGenerator<Gizmo>()

View File

@ -86,12 +86,19 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.ComplexGibbit"
]
}
},
"required": [
"b",
"c",
"z"
"z",
"type"
]
},
"SimpleGibbit": {
@ -102,10 +109,17 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.SimpleGibbit"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"FlibbityGibbit": {

View File

@ -82,11 +82,18 @@
},
"f": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.Bibbity"
]
}
},
"required": [
"b",
"f"
"f",
"type"
]
},
"Gibbity-String": {
@ -94,10 +101,17 @@
"properties": {
"a": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.Gibbity"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"Flibbity-String": {

View File

@ -65,12 +65,19 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.ComplexGibbit"
]
}
},
"required": [
"b",
"c",
"z"
"z",
"type"
]
},
"SimpleGibbit": {
@ -81,10 +88,17 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.SimpleGibbit"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"FlibbityGibbit": {

View File

@ -65,12 +65,19 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.ComplexGibbit"
]
}
},
"required": [
"b",
"c",
"z"
"z",
"type"
]
},
"SimpleGibbit": {
@ -81,10 +88,17 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.SimpleGibbit"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"List-FlibbityGibbit": {

View File

@ -65,12 +65,19 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.ComplexGibbit"
]
}
},
"required": [
"b",
"c",
"z"
"z",
"type"
]
},
"SimpleGibbit": {
@ -81,10 +88,17 @@
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.SimpleGibbit"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"Map-String-FlibbityGibbit": {

View File

@ -62,11 +62,18 @@
"f": {
"type": "number",
"format": "double"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.Bibbity"
]
}
},
"required": [
"b",
"f"
"f",
"type"
]
},
"Gibbity-Double": {
@ -75,10 +82,17 @@
"a": {
"type": "number",
"format": "double"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.Gibbity"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"Flibbity-Double": {

View File

@ -53,40 +53,6 @@
"webhooks": {},
"components": {
"schemas": {
"ComplexGibbit": {
"type": "object",
"properties": {
"b": {
"type": "string"
},
"c": {
"type": "number",
"format": "int32"
},
"z": {
"type": "string"
}
},
"required": [
"b",
"c",
"z"
]
},
"SimpleGibbit": {
"type": "object",
"properties": {
"a": {
"type": "string"
},
"z": {
"type": "string"
}
},
"required": [
"a"
]
},
"Bibbity-FlibbityGibbit": {
"type": "object",
"properties": {
@ -102,11 +68,66 @@
"$ref": "#/components/schemas/SimpleGibbit"
}
]
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.Bibbity"
]
}
},
"required": [
"b",
"f"
"f",
"type"
]
},
"ComplexGibbit": {
"type": "object",
"properties": {
"b": {
"type": "string"
},
"c": {
"type": "number",
"format": "int32"
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.ComplexGibbit"
]
}
},
"required": [
"b",
"c",
"z",
"type"
]
},
"SimpleGibbit": {
"type": "object",
"properties": {
"a": {
"type": "string"
},
"z": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.SimpleGibbit"
]
}
},
"required": [
"a",
"type"
]
},
"Gibbity-FlibbityGibbit": {
@ -121,10 +142,17 @@
"$ref": "#/components/schemas/SimpleGibbit"
}
]
},
"type": {
"type": "string",
"enum": [
"io.bkbn.kompendium.core.fixtures.Gibbity"
]
}
},
"required": [
"a"
"a",
"type"
]
},
"Flibbity-FlibbityGibbit": {

View File

@ -0,0 +1,108 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChillaxificationMaximization"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"Chillax": {
"type": "object",
"properties": {
"a": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"chillax"
]
}
},
"required": [
"a",
"type"
]
},
"ToDaMax": {
"type": "object",
"properties": {
"b": {
"type": "number",
"format": "int32"
},
"type": {
"type": "string",
"enum": [
"maximize"
]
}
},
"required": [
"b",
"type"
]
},
"ChillaxificationMaximization": {
"anyOf": [
{
"$ref": "#/components/schemas/Chillax"
},
{
"$ref": "#/components/schemas/ToDaMax"
}
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,72 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Gizmo"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"Gizmo": {
"type": "object",
"properties": {
"title": {
"type": "string"
}
},
"required": [
"title"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,92 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
},
{
"name": "aa",
"in": "query",
"schema": {
"type": "number",
"format": "int32"
},
"required": true,
"deprecated": false
}
]
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,94 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/field_outside_constructor": {
"post": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"requestBody": {
"description": "A cool request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SomethingSimilar"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Cool response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
},
"SomethingSimilar": {
"type": "object",
"properties": {
"a": {
"type": "string"
}
},
"required": [
"a"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,129 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false,
"security": [
{
"auth-oauth-google": [
"read:pets"
]
}
]
},
"post": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"requestBody": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false,
"security": [
{
"auth-oauth-google": [
"write:pets"
]
}
]
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
}
},
"securitySchemes": {
"auth-oauth-google": {
"flows": {
"implicit": {
"authorizationUrl": "https://accounts.google.com/o/oauth2/auth",
"scopes": {
"write:pets": "modify pets in your account",
"read:pets": "read your pets"
}
}
},
"type": "oauth2"
}
}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,140 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"post": {
"tags": [],
"summary": "This is a summary",
"description": "This is a description",
"parameters": [],
"requestBody": {
"description": "This is a request description",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestRequest"
},
"examples": {
"Testerina": {
"value": {
"fieldName": {
"nesty": "asdf"
},
"b": 1.5,
"aaa": []
},
"summary": "summary",
"description": "description"
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "This is a response description",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
},
"examples": {
"Testerino": {
"value": {
"c": "Heya"
},
"summary": "summary",
"description": "description"
}
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
},
"TestRequest": {
"type": "object",
"properties": {
"aaa": {
"items": {
"type": "number",
"format": "int64"
},
"type": "array"
},
"b": {
"type": "number",
"format": "double"
},
"fieldName": {
"$ref": "#/components/schemas/TestNested"
}
},
"required": [
"aaa",
"b",
"fieldName"
]
},
"TestNested": {
"type": "object",
"properties": {
"nesty": {
"type": "string"
}
},
"required": [
"nesty"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -1,54 +0,0 @@
package io.bkbn.kompendium.core.fixtures
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
/*
These are test implementation and may well be a good starting point for creating production ones.
Both Gson and Jackson are complex and can achieve this things is more than one way therefore
these will not always work hence why they are in the test package
*/
class GsonSchemaConfigurator: SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> {
// NOTE: This is test logic Expose is set at a global Gson level so configure to match your Gson set up
val hasAnyExpose = clazz.memberProperties.any { it.hasJavaAnnotation<Expose>() }
return if(hasAnyExpose) {
clazz.memberProperties
.filter { it.hasJavaAnnotation<Expose>() }
} else clazz.memberProperties
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<SerializedName>()?.value?: property.name
}
class JacksonSchemaConfigurator: SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
.filterNot {
it.hasJavaAnnotation<JsonIgnore>()
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<JsonProperty>()?.value?: property.name
}
inline fun <reified T: Annotation> KProperty1<*, *>.hasJavaAnnotation(): Boolean {
return javaField?.isAnnotationPresent(T::class.java)?: false
}
inline fun <reified T: Annotation> KProperty1<*, *>.getJavaAnnotation(): T? {
return javaField?.getDeclaredAnnotation(T::class.java)
}

View File

@ -1,7 +0,0 @@
package io.bkbn.kompendium.core.fixtures
enum class SupportedSerializer {
KOTLINX,
GSON,
JACKSON
}

View File

@ -1,7 +1,5 @@
package io.bkbn.kompendium.core.fixtures
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.SerializationFeature
import io.bkbn.kompendium.core.fixtures.TestSpecs.defaultSpec
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.routes.redoc
@ -16,19 +14,17 @@ import io.kotest.matchers.shouldNot
import io.kotest.matchers.string.beBlank
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.gson.gson
import io.ktor.serialization.jackson.jackson
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.engine.ApplicationEngineEnvironmentBuilder
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.contentnegotiation.ContentNegotiationConfig
import io.ktor.server.routing.Routing
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import java.io.File
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.reflect.KType
object TestHelpers {
@ -55,7 +51,7 @@ object TestHelpers {
/**
* This will take a provided JSON snapshot file, retrieve it from the resource folder,
* and build a test ktor server to compare the expected output with the output found in the default
* OpenAPI json endpoint. By default, this will run the same test with Gson, Kotlinx, and Jackson serializers
* OpenAPI json endpoint.
* @param snapshotName The snapshot file to retrieve from the resources folder
*/
fun openApiTestAllSerializers(
@ -64,70 +60,47 @@ object TestHelpers {
applicationSetup: Application.() -> Unit = { },
specOverrides: OpenApiSpec.() -> OpenApiSpec = { this },
applicationEnvironmentBuilder: ApplicationEngineEnvironmentBuilder.() -> Unit = {},
notarizedApplicationConfigOverrides: NotarizedApplication.Config.() -> Unit = {},
contentNegotiation: ContentNegotiationConfig.() -> Unit = {
json(Json {
encodeDefaults = true
explicitNulls = false
serializersModule = KompendiumSerializersModule.module
})
},
routeUnderTest: Routing.() -> Unit
) {
openApiTest(
snapshotName,
SupportedSerializer.KOTLINX,
routeUnderTest,
applicationSetup,
specOverrides,
customTypes,
applicationEnvironmentBuilder
)
openApiTest(
snapshotName,
SupportedSerializer.JACKSON,
routeUnderTest,
applicationSetup,
specOverrides,
customTypes,
applicationEnvironmentBuilder
)
openApiTest(
snapshotName,
SupportedSerializer.GSON,
routeUnderTest,
applicationSetup,
specOverrides,
customTypes,
notarizedApplicationConfigOverrides,
contentNegotiation,
applicationEnvironmentBuilder
)
}
private fun openApiTest(
snapshotName: String,
serializer: SupportedSerializer,
routeUnderTest: Routing.() -> Unit,
applicationSetup: Application.() -> Unit,
specOverrides: OpenApiSpec.() -> OpenApiSpec,
typeOverrides: Map<KType, JsonSchema> = emptyMap(),
applicationBuilder: ApplicationEngineEnvironmentBuilder.() -> Unit = {}
notarizedApplicationConfigOverrides: NotarizedApplication.Config.() -> Unit,
contentNegotiation: ContentNegotiationConfig.() -> Unit,
applicationBuilder: ApplicationEngineEnvironmentBuilder.() -> Unit
) = testApplication {
environment(applicationBuilder)
install(NotarizedApplication()) {
customTypes = typeOverrides
spec = defaultSpec().specOverrides()
schemaConfigurator = when (serializer) {
SupportedSerializer.KOTLINX -> KotlinXSchemaConfigurator()
SupportedSerializer.GSON -> GsonSchemaConfigurator()
SupportedSerializer.JACKSON -> JacksonSchemaConfigurator()
}
spec = { specOverrides(defaultSpec()) }
schemaConfigurator = KotlinXSchemaConfigurator()
notarizedApplicationConfigOverrides()
}
install(ContentNegotiation) {
when (serializer) {
SupportedSerializer.KOTLINX -> json(Json {
encodeDefaults = true
explicitNulls = false
serializersModule = KompendiumSerializersModule.module
})
SupportedSerializer.GSON -> gson()
SupportedSerializer.JACKSON -> jackson(ContentType.Application.Json) {
enable(SerializationFeature.INDENT_OUTPUT)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}
contentNegotiation()
}
application(applicationSetup)
routing {

View File

@ -1,12 +1,10 @@
package io.bkbn.kompendium.core.fixtures
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.Instant
@Serializable
@ -91,6 +89,25 @@ data class AnothaJamma(val b: Float) : SlammaJamma
data class InsaneJamma(val c: SlammaJamma) : SlammaJamma
sealed interface ChillaxificationMaximization
@Serializable
@SerialName("chillax")
data class Chillax(val a: String) : ChillaxificationMaximization
@Serializable
@SerialName("maximize")
data class ToDaMax(val b: Int) : ChillaxificationMaximization
sealed class Gadget(
open val title: String,
open val description: String
)
class Gizmo(
override val title: String,
) : Gadget(title, "Just a gizmo")
sealed interface Flibbity<T>
data class Gibbity<T>(val a: T) : Flibbity<T>
@ -158,9 +175,7 @@ object Nested {
@Serializable
data class TransientObject(
@field:Expose
val nonTransient: String,
@field:JsonIgnore
@Transient
val transient: String = "transient"
)
@ -174,8 +189,6 @@ data class UnbackedObject(
@Serializable
data class SerialNameObject(
@field:JsonProperty("snake_case_name")
@field:SerializedName("snake_case_name")
@SerialName("snake_case_name")
val camelCaseName: String
)
@ -194,3 +207,8 @@ enum class Color {
data class ObjectWithEnum(
val color: Color
)
@Serializable
data class SomethingSimilar(val a: String) {
val b = "something else"
}

View File

@ -1,5 +1,5 @@
# Kompendium
project.version=3.14.4
project.version=4.0.0-alpha
# Kotlin
kotlin.code.style=official
# Gradle

View File

@ -1,20 +1,51 @@
package io.bkbn.kompendium.json.schema
import io.bkbn.kompendium.json.schema.definition.EnumDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import kotlinx.serialization.SerialName
import kotlinx.serialization.Transient
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.KType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
class KotlinXSchemaConfigurator : SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> {
return clazz.memberProperties
.filterNot { it.hasAnnotation<Transient>() }
.filter { clazz.primaryConstructor?.parameters?.map { it.name }?.contains(it.name) ?: true }
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.annotations
.filterIsInstance<SerialName>()
.firstOrNull()?.value ?: property.name
override fun sealedTypeEnrichment(
implementationType: KType,
implementationSchema: JsonSchema,
): JsonSchema {
return if (implementationSchema is TypeDefinition && implementationSchema.type == "object") {
implementationSchema.copy(
required = implementationSchema.required?.plus("type"),
properties = implementationSchema.properties?.plus(
mapOf(
"type" to EnumDefinition("string", enum = setOf(determineTypeQualifier(implementationType)))
)
)
)
} else {
implementationSchema
}
}
private fun determineTypeQualifier(type: KType): String {
val nameOverrideAnnotation = (type.classifier as KClass<*>).findAnnotation<SerialName>()
return nameOverrideAnnotation?.value ?: (type.classifier as KClass<*>).qualifiedName!!
}
}

View File

@ -1,17 +1,16 @@
package io.bkbn.kompendium.json.schema
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.KType
interface SchemaConfigurator {
fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>>
fun serializableName(property: KProperty1<out Any, *>): String
open class Default : SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
override fun serializableName(property: KProperty1<out Any, *>): String = property.name
}
fun sealedTypeEnrichment(
implementationType: KType,
implementationSchema: JsonSchema
): JsonSchema
}

View File

@ -25,7 +25,10 @@ object SealedObjectHandler {
val subclasses = clazz.sealedSubclasses
.map { it.createType(type.arguments) }
.map { t ->
SchemaGenerator.fromTypeToSchema(t, cache, schemaConfigurator, enrichment).let { js ->
SchemaGenerator.fromTypeToSchema(t, cache, schemaConfigurator, enrichment)
.let {
schemaConfigurator.sealedTypeEnrichment(t, it)
}.let { js ->
if (js is TypeDefinition && js.type == "object") {
val slug = t.getSlug(enrichment)
cache[slug] = js

View File

@ -13,7 +13,7 @@ object Helpers {
fun KType.getSlug(enrichment: Enrichment? = null) = when (enrichment) {
is TypeEnrichment<*> -> getEnrichedSlug(enrichment)
is PropertyEnrichment -> error("Slugs should not be generated for field enrichments")
null -> getSimpleSlug()
else -> getSimpleSlug()
}
fun KType.getSimpleSlug(): String = when {
@ -26,7 +26,7 @@ object Helpers {
fun KType.getReferenceSlug(enrichment: Enrichment? = null): String = when (enrichment) {
is TypeEnrichment<*> -> getSimpleReferenceSlug() + "-${enrichment.id}"
is PropertyEnrichment -> error("Reference slugs should never be generated for field enrichments")
null -> getSimpleReferenceSlug()
else -> getSimpleReferenceSlug()
}
private fun KType.getSimpleReferenceSlug() = when {

View File

@ -20,5 +20,5 @@ data class MediaType(
val encoding: Map<String, Encoding>? = null,
) {
@Serializable
data class Example(@Contextual val value: Any)
data class Example(@Contextual val value: Any, val summary: String? = null, val description: String? = null)
}

View File

@ -1,7 +1,6 @@
package io.bkbn.kompendium.oas.payload
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
/**
@ -26,12 +25,9 @@ data class Parameter(
val required: Boolean = true,
val deprecated: Boolean = false,
val allowEmptyValue: Boolean? = null,
val examples: Map<String, Example>? = null,
val examples: Map<String, MediaType.Example>? = null,
// todo support styling https://spec.openapis.org/oas/v3.1.0#style-values
) {
@Serializable
data class Example(@Contextual val value: Any)
@Suppress("EnumNaming")
@Serializable
enum class Location {

View File

@ -60,7 +60,8 @@ private fun Application.mainModule() {
}
}
install(NotarizedApplication()) {
spec = baseSpec.copy(
spec = {
baseSpec.copy(
components = Components(
securitySchemes = mutableMapOf(
"basic" to BasicAuth()
@ -68,6 +69,7 @@ private fun Application.mainModule() {
)
)
}
}
routing {
swagger(pageTitle = "Simple API Docs")
redoc(pageTitle = "Simple API Docs")

View File

@ -44,9 +44,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
// Adds support for @Transient and @SerialName
// If you are not using them this is not required.
spec = { baseSpec }
schemaConfigurator = KotlinXSchemaConfigurator()
}
routing {

View File

@ -43,9 +43,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
// Adds support for @Transient and @SerialName
// If you are not using them this is not required.
spec = { baseSpec }
schemaConfigurator = KotlinXSchemaConfigurator()
}
routing {

View File

@ -1,19 +1,19 @@
package io.bkbn.kompendium.playground
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.core.routes.swagger
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.component.Components
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.oas.security.BasicAuth
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
import io.bkbn.kompendium.playground.util.ExampleResponse
import io.bkbn.kompendium.playground.util.Util.baseSpec
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.gson.gson
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
@ -21,14 +21,13 @@ import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
embeddedServer(
@ -38,20 +37,44 @@ fun main() {
).start(wait = true)
}
private val CustomJsonEncoder = Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = false
}
private fun Application.mainModule() {
install(ContentNegotiation) {
gson {
setPrettyPrinting()
}
json(Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = true
})
}
install(NotarizedApplication()) {
spec = baseSpec
schemaConfigurator = GsonSchemaConfigurator()
spec = {
baseSpec.copy(
components = Components(
securitySchemes = mutableMapOf(
"basic" to BasicAuth()
)
)
)
}
specRoute = { spec, routing ->
routing {
route("/openapi.json") {
get {
call.response.headers.append("Content-Type", "application/json")
call.respondText { CustomJsonEncoder.encodeToString(spec) }
}
}
}
}
}
routing {
swagger(pageTitle = "Simple API Docs")
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
locationDocumentation()
get {
@ -73,6 +96,9 @@ private fun Route.locationDocumentation() {
get = GetInfo.builder {
summary("Get user by id")
description("A very neat endpoint!")
security = mapOf(
"basic" to emptyList()
)
response {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
@ -81,27 +107,3 @@ private fun Route.locationDocumentation() {
}
}
}
// Adds support for Expose and SerializedName annotations,
// if you are not using them this is not required
class GsonSchemaConfigurator(
private val excludeFieldsWithoutExposeAnnotation: Boolean = false
): SchemaConfigurator.Default() {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> {
return if(excludeFieldsWithoutExposeAnnotation) clazz.memberProperties
.filter { it.hasJavaAnnotation<Expose>() }
else clazz.memberProperties
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<SerializedName>()?.value?: property.name
}
private inline fun <reified T: Annotation> KProperty1<*, *>.hasJavaAnnotation(): Boolean {
return javaField?.isAnnotationPresent(T::class.java)?: false
}
private inline fun <reified T: Annotation> KProperty1<*, *>.getJavaAnnotation(): T? {
return javaField?.getDeclaredAnnotation(T::class.java)
}

View File

@ -46,7 +46,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
spec = { baseSpec }
customTypes = mapOf(
typeOf<Instant>() to TypeDefinition(type = "string", format = "date-time")
)

View File

@ -45,7 +45,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
spec = { baseSpec }
// Adds support for @Transient and @SerialName
// If you are not using them this is not required.
schemaConfigurator = KotlinXSchemaConfigurator()

View File

@ -1,21 +1,20 @@
package io.bkbn.kompendium.playground
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.SerializationFeature
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.core.routes.swagger
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.MediaType
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
import io.bkbn.kompendium.playground.util.ExampleResponse
import io.bkbn.kompendium.playground.util.ExceptionResponse
import io.bkbn.kompendium.playground.util.Util.baseSpec
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.jackson.jackson
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
@ -27,10 +26,7 @@ import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import kotlinx.serialization.json.Json
fun main() {
embeddedServer(
@ -42,29 +38,35 @@ fun main() {
private fun Application.mainModule() {
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
json(Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = false
})
}
install(NotarizedApplication()) {
spec = baseSpec
schemaConfigurator = JacksonSchemaConfigurator()
spec = { baseSpec }
schemaConfigurator = KotlinXSchemaConfigurator()
}
routing {
swagger(pageTitle = "Simple API Docs")
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
locationDocumentation()
idDocumentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
route("/profile") {
profileDocumentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
}
}
}
}
private fun Route.locationDocumentation() {
private fun Route.idDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(
@ -80,31 +82,42 @@ private fun Route.locationDocumentation() {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Will return whether or not the user is real 😱")
examples(
"example1" to MediaType.Example(ExampleResponse(true), "ahaha", "bhbh"),
)
}
canRespond {
responseType<ExceptionResponse>()
responseCode(HttpStatusCode.NotFound)
description("Indicates that a user with this id does not exist")
}
}
}
}
// Adds support for JsonIgnore and JsonProperty annotations,
// if you are not using them this is not required
// This also does not support class level configuration
private class JacksonSchemaConfigurator: SchemaConfigurator.Default() {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
.filterNot {
it.hasJavaAnnotation<JsonIgnore>()
private fun Route.profileDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(
name = "id",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
)
)
get = GetInfo.builder {
summary("Get a users profile")
description("A cool endpoint!")
response {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Returns user profile information")
}
canRespond {
responseType<ExceptionResponse>()
responseCode(HttpStatusCode.NotFound)
description("Indicates that a user with this id does not exist")
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<JsonProperty>()?.value?: property.name
}
private inline fun <reified T: Annotation> KProperty1<*, *>.hasJavaAnnotation(): Boolean {
return javaField?.isAnnotationPresent(T::class.java)?: false
}
private inline fun <reified T: Annotation> KProperty1<*, *>.getJavaAnnotation(): T? {
return javaField?.getDeclaredAnnotation(T::class.java)
}

View File

@ -43,7 +43,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
spec = { baseSpec }
}
install(StatusPages) {
exception<Throwable> { call, _ ->

View File

@ -62,18 +62,22 @@ private fun Application.mainModule() {
}
}
install(NotarizedApplication()) {
spec = baseSpec.copy(
spec = {
baseSpec.copy(
components = Components(
securitySchemes = mutableMapOf(
"basic" to BasicAuth()
)
)
)
openApiJson = {
}
specRoute = { spec, routing ->
routing {
authenticate("basic") {
route("/openapi.json") {
get {
call.respond(HttpStatusCode.OK, this@route.application.attributes[KompendiumAttributes.openApiSpec])
call.respond(HttpStatusCode.OK, spec)
}
}
}
}

View File

@ -43,7 +43,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
spec = { baseSpec }
}
install(NotarizedLocations()) {
locations = mapOf(

View File

@ -44,7 +44,7 @@ private fun Application.mainModule() {
})
}
install(NotarizedApplication()) {
spec = baseSpec
spec = { baseSpec }
}
install(NotarizedResources()) {
resources = mapOf(