Compare commits

..

11 Commits

51 changed files with 1326 additions and 1301 deletions

View File

@ -5,6 +5,9 @@
### Added
### Changed
- Can now put redoc route behind authentication
- Fixed issue where type erasure was breaking nested generics
- Fix bug with null references not displaying properly on properties
### Remove
@ -12,6 +15,17 @@
## Released
## [3.0.0] - August 16th, 2022
### Added
- Ktor 2 Support 🎉
- OpenAPI 3.1 Standard
- JsonSchema Generator
- `NotarizedRoute` plugin
### Removed
- SwaggerUI module removed (due to lack of OpenAPI 3.1 support)
- Kompendium Annotations removed (field renames, undeclared fields, etc. will be follow-up work)
## [2.3.5] - June 7th, 2022
### Added

View File

@ -1,6 +1,6 @@
# Kompendium
Welcome to Kompendium, the straight-forward, minimally-invasive OpenAPI generator for Ktor.
Welcome to Kompendium, the straight-forward, non-invasive OpenAPI generator for Ktor.
## How to install
@ -21,21 +21,74 @@ In addition to publishing releases to Maven Central, a snapshot version gets pub
to `main`. These can be consumed by adding the repository to your gradle build file. Instructions can be
found [here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#using-a-published-package)
## Setting up the Kompendium Plugin
## Setting up Kompendium
Kompendium is instantiated as a Ktor Feature/Plugin. It can be added to your API as follows
Kompendium's core features are comprised of a singular application level plugin and a collection of route level plugins.
The former sets up your OpenApi spec along with various cross-route metadata and overrides such as custom types (useful
for things like datetime libraries)
### `NotarizedApplication` plugin
The notarized application plugin is installed at (surprise!) the app level
```kotlin
private fun Application.mainModule() {
// Installs the Kompendium Plugin and sets up baseline server metadata
install(Kompendium) {
spec = OpenApiSpec(/*..*/)
install(NotarizedApplication()) {
spec = OpenApiSpec(
// spec details go here ...
)
}
// ...
}
```
## Notarization
### `NotarizedRoute` plugin
The concept of notarizing routes / exceptions / etc. is central to Kompendium. More details on _how_ to notarize your
API can be found in the kompendium-core module.
Notarized routes take advantage of Ktor 2's [route specific plugin](https://ktor.io/docs/plugins.html#install-route)
feature. This allows us to take individual routes, document them, and feed them back in to the application level plugin.
This also allows you to adopt Kompendium incrementally. Individual routes can be documented at your leisure, and is
purely
additive, meaning that you do not need to modify existing code to get documentation working, you just need new code!
Non-invasive FTW 🚀
Documenting a simple `GET` endpoint would look something like this
```kotlin
private fun Route.documentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(
name = "id",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
)
)
get = GetInfo.builder {
summary("Get user by id")
description("A very neat endpoint!")
response {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Will return whether or not the user is real 😱")
}
}
}
}
route("/{id}") {
documentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
}
```
Full details on application and route notarization can be found in the `core` module
## The Playground
In addition to the documentation available here, Kompendium has a number of out-of-the-box examples available in the
playground module. Go ahead and fork the repo and run them directly on your machine to get a sense of what Kompendium
can do!

View File

@ -1,9 +1,9 @@
plugins {
kotlin("jvm") version "1.7.10" apply false
kotlin("plugin.serialization") version "1.7.10" apply false
id("io.bkbn.sourdough.library.jvm") version "0.9.0" apply false
id("io.bkbn.sourdough.application.jvm") version "0.9.0" apply false
id("io.bkbn.sourdough.root") version "0.9.0"
id("io.bkbn.sourdough.library.jvm") version "0.9.1" apply false
id("io.bkbn.sourdough.application.jvm") version "0.9.1" apply false
id("io.bkbn.sourdough.root") version "0.9.1"
id("com.github.jakemarsden.git-hooks") version "0.0.2"
id("org.jetbrains.dokka") version "1.7.10"
id("org.jetbrains.kotlinx.kover") version "0.5.1"

View File

@ -6,12 +6,14 @@ It is also the only mandatory client-facing module for a basic setup.
# Package io.bkbn.kompendium.core
The root package contains several objects that power Kompendium, including the Kompendium Ktor Plugin, route
notarization methods, and the reflection engine that analyzes method info type parameters.
## Plugins
## Plugin
As mentioned in the root documentation, there are two core Kompendium plugins.
The Kompendium plugin is an extremely light-weight plugin, with only a couple areas of customization.
1. The application level plugin that handles app level metadata, configuring up your OpenAPI spec, managing custom data
types, etc.
2. The route level plugin, which is how users declare the documentation for the given route. It _must_ be installed on
every route you wish to document
### Serialization
@ -29,76 +31,18 @@ serializer module will convert any `Any` serialization to be `Contextual`. This
only way to get Kotlinx to play nice with serializing `Any`. If you come up with a better solution, definitely go ahead
and open up a PR!
## Notarization
## NotarizedApplication
Central to Kompendium is the concept of notarization.
TODO
Notarizing a route is the mechanism by which Kompendium analyzes your route types, along with provided metadata, and
converts to the expected OpenAPI format.
## NotarizedRoute
Before jumping into notarization, lets first look at a standard Ktor route
```kotlin
routing {
get {
call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
}
}
```
Now, let's compare this to the same functionality, but notarized using Kompendium
```kotlin
routing {
notarizedGet(simpleGetExample) {
call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
}
}
```
Pretty simple huh. But hold on... what is this `simpleGetExample`? How can I know that it is so "simple". Let's take a
look
```kotlin
val simpleGetExample = GetInfo<Unit, BasicResponse>(
summary = "Simple, Documented GET Request",
description = "This is to showcase just how easy it is to document your Ktor API!",
responseInfo = ResponseInfo(
status = HttpStatusCode.OK,
description = "This means everything went as expected!",
examples = mapOf("demo" to BasicResponse(c = "52c099d7-8642-46cc-b34e-22f39b923cf4"))
),
tags = setOf("Simple")
)
```
See, not so bad 😄 `GetInfo<*,*>` is an implementation of `MethodInfo<TParam, TResp>`, a sealed interface designed to
encapsulate all the metadata required for documenting an API route. Kompendium leverages this data, along with the
provided type parameters `TParam` and `TResp` to construct the full OpenAPI Specification for your route.
Additionally, just as a backup, each notarization method includes a "post-processing' hook that will allow you to have
final say in the generated route info prior to being attached to the spec. This can be accessed via the optional
parameter
```kotlin
routing {
notarizedGet(simpleGetExample, postProcess = { spec -> spec }) {
call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
}
}
```
This should only be used in _extremely_ rare scenarios, but it is nice to know it is there if you need it.
TODO
# Package io.bkbn.kompendium.core.metadata
Houses all interfaces and types related to describing route metadata.
# Package io.bkbn.kompendium.core.parser
Responsible for the parse of method information. Base implementation is an interface to support extensibility as shown
in the `kompendium-locations` module.
# Package io.bkbn.kompendium.core.routes
Houses any routes provided by the core module. At the moment the only supported route is to enable ReDoc support.

View File

@ -4,44 +4,32 @@ import io.bkbn.kompendium.core.attribute.KompendiumAttributes
import io.bkbn.kompendium.core.metadata.DeleteInfo
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.HeadInfo
import io.bkbn.kompendium.core.metadata.MethodInfo
import io.bkbn.kompendium.core.metadata.MethodInfoWithRequest
import io.bkbn.kompendium.core.metadata.OptionsInfo
import io.bkbn.kompendium.core.metadata.PatchInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
import io.bkbn.kompendium.core.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.core.util.Helpers.getSimpleSlug
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.oas.OpenApiSpec
import io.bkbn.kompendium.core.util.Helpers.addToSpec
import io.bkbn.kompendium.core.util.SpecConfig
import io.bkbn.kompendium.oas.path.Path
import io.bkbn.kompendium.oas.path.PathOperation
import io.bkbn.kompendium.oas.payload.MediaType
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.oas.payload.Request
import io.bkbn.kompendium.oas.payload.Response
import io.ktor.server.application.ApplicationCallPipeline
import io.ktor.server.application.Hook
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.routing.Route
import kotlin.reflect.KClass
import kotlin.reflect.KType
object NotarizedRoute {
class Config {
var tags: Set<String> = emptySet()
var parameters: List<Parameter> = emptyList()
var get: GetInfo? = null
var post: PostInfo? = null
var put: PutInfo? = null
var delete: DeleteInfo? = null
var patch: PatchInfo? = null
var head: HeadInfo? = null
var options: OptionsInfo? = null
var security: Map<String, List<String>>? = null
class Config : SpecConfig {
override var tags: Set<String> = emptySet()
override var parameters: List<Parameter> = emptyList()
override var get: GetInfo? = null
override var post: PostInfo? = null
override var put: PutInfo? = null
override var delete: DeleteInfo? = null
override var patch: PatchInfo? = null
override var head: HeadInfo? = null
override var options: OptionsInfo? = null
override var security: Map<String, List<String>>? = null
internal var path: Path? = null
}
@ -86,86 +74,5 @@ object NotarizedRoute {
pluginConfig.path = path
}
private fun MethodInfo.addToSpec(path: Path, spec: OpenApiSpec, config: Config) {
SchemaGenerator.fromTypeOrUnit(this.response.responseType, spec.components.schemas)?.let { schema ->
spec.components.schemas[this.response.responseType.getSimpleSlug()] = schema
}
errors.forEach { error ->
SchemaGenerator.fromTypeOrUnit(error.responseType, spec.components.schemas)?.let { schema ->
spec.components.schemas[error.responseType.getSimpleSlug()] = schema
}
}
when (this) {
is MethodInfoWithRequest -> {
SchemaGenerator.fromTypeOrUnit(this.request.requestType, spec.components.schemas)?.let { schema ->
spec.components.schemas[this.request.requestType.getSimpleSlug()] = schema
}
}
else -> {}
}
val operations = this.toPathOperation(config)
when (this) {
is DeleteInfo -> path.delete = operations
is GetInfo -> path.get = operations
is HeadInfo -> path.head = operations
is PatchInfo -> path.patch = operations
is PostInfo -> path.post = operations
is PutInfo -> path.put = operations
is OptionsInfo -> path.options = operations
}
}
private fun MethodInfo.toPathOperation(config: Config) = PathOperation(
tags = config.tags.plus(this.tags),
summary = this.summary,
description = this.description,
externalDocs = this.externalDocumentation,
operationId = this.operationId,
deprecated = this.deprecated,
parameters = this.parameters,
security = config.security
?.map { (k, v) -> k to v }
?.map { listOf(it).toMap() }
?.toList(),
requestBody = when (this) {
is MethodInfoWithRequest -> Request(
description = this.request.description,
content = this.request.requestType.toReferenceContent(this.request.examples),
required = true
)
else -> null
},
responses = mapOf(
this.response.responseCode.value to Response(
description = this.response.description,
content = this.response.responseType.toReferenceContent(this.response.examples)
)
).plus(this.errors.toResponseMap())
)
private fun List<ResponseInfo>.toResponseMap(): Map<Int, Response> = associate { error ->
error.responseCode.value to Response(
description = error.description,
content = error.responseType.toReferenceContent(error.examples)
)
}
private fun KType.toReferenceContent(examples: Map<String, MediaType.Example>?): Map<String, MediaType>? =
when (this.classifier as KClass<*>) {
Unit::class -> null
else -> mapOf(
"application/json" to MediaType(
schema = ReferenceDefinition(this.getReferenceSlug()),
examples = examples
)
)
}
private fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "")
}

View File

@ -3,7 +3,7 @@ package io.bkbn.kompendium.core.routes
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.html.respondHtml
import io.ktor.server.routing.Routing
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.html.body
@ -20,7 +20,7 @@ import kotlinx.html.unsafe
* @param pageTitle Webpage title you wish to be displayed on your docs
* @param specUrl url to point ReDoc to the OpenAPI json document
*/
fun Routing.redoc(pageTitle: String = "Docs", specUrl: String = "/openapi.json") {
fun Route.redoc(pageTitle: String = "Docs", specUrl: String = "/openapi.json") {
route("/docs") {
get {
call.respondHtml(HttpStatusCode.OK) {

View File

@ -1,35 +1,108 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.metadata.DeleteInfo
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.HeadInfo
import io.bkbn.kompendium.core.metadata.MethodInfo
import io.bkbn.kompendium.core.metadata.MethodInfoWithRequest
import io.bkbn.kompendium.core.metadata.OptionsInfo
import io.bkbn.kompendium.core.metadata.PatchInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import io.bkbn.kompendium.oas.OpenApiSpec
import io.bkbn.kompendium.oas.path.Path
import io.bkbn.kompendium.oas.path.PathOperation
import io.bkbn.kompendium.oas.payload.MediaType
import io.bkbn.kompendium.oas.payload.Request
import io.bkbn.kompendium.oas.payload.Response
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.jvm.javaField
import org.slf4j.LoggerFactory
import java.lang.reflect.ParameterizedType
import java.util.Locale
object Helpers {
private const val COMPONENT_SLUG = "#/components/schemas"
fun KType.getSimpleSlug(): String = when {
this.arguments.isNotEmpty() -> genericNameAdapter(this, classifier as KClass<*>)
else -> (classifier as KClass<*>).simpleName ?: error("Could not determine simple name for $this")
fun MethodInfo.addToSpec(path: Path, spec: OpenApiSpec, config: SpecConfig) {
SchemaGenerator.fromTypeOrUnit(this.response.responseType, spec.components.schemas)?.let { schema ->
spec.components.schemas[this.response.responseType.getSimpleSlug()] = schema
}
fun KType.getReferenceSlug(): String = when {
arguments.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, classifier as KClass<*>)}"
else -> "$COMPONENT_SLUG/${(classifier as KClass<*>).simpleName}"
errors.forEach { error ->
SchemaGenerator.fromTypeOrUnit(error.responseType, spec.components.schemas)?.let { schema ->
spec.components.schemas[error.responseType.getSimpleSlug()] = schema
}
}
/**
* Adapts a class with type parameters into a reference friendly string
*/
private fun genericNameAdapter(type: KType, clazz: KClass<*>): String {
val classNames = type.arguments
.map { it.type?.classifier as KClass<*> }
.map { it.simpleName }
return classNames.joinToString(separator = "-", prefix = "${clazz.simpleName}-")
when (this) {
is MethodInfoWithRequest -> {
SchemaGenerator.fromTypeOrUnit(this.request.requestType, spec.components.schemas)?.let { schema ->
spec.components.schemas[this.request.requestType.getSimpleSlug()] = schema
}
}
else -> {}
}
val operations = this.toPathOperation(config)
when (this) {
is DeleteInfo -> path.delete = operations
is GetInfo -> path.get = operations
is HeadInfo -> path.head = operations
is PatchInfo -> path.patch = operations
is PostInfo -> path.post = operations
is PutInfo -> path.put = operations
is OptionsInfo -> path.options = operations
}
}
private fun MethodInfo.toPathOperation(config: SpecConfig) = PathOperation(
tags = config.tags.plus(this.tags),
summary = this.summary,
description = this.description,
externalDocs = this.externalDocumentation,
operationId = this.operationId,
deprecated = this.deprecated,
parameters = this.parameters,
security = config.security
?.map { (k, v) -> k to v }
?.map { listOf(it).toMap() }
?.toList(),
requestBody = when (this) {
is MethodInfoWithRequest -> Request(
description = this.request.description,
content = this.request.requestType.toReferenceContent(this.request.examples),
required = true
)
else -> null
},
responses = mapOf(
this.response.responseCode.value to Response(
description = this.response.description,
content = this.response.responseType.toReferenceContent(this.response.examples)
)
).plus(this.errors.toResponseMap())
)
private fun List<ResponseInfo>.toResponseMap(): Map<Int, Response> = associate { error ->
error.responseCode.value to Response(
description = error.description,
content = error.responseType.toReferenceContent(error.examples)
)
}
private fun KType.toReferenceContent(examples: Map<String, MediaType.Example>?): Map<String, MediaType>? =
when (this.classifier as KClass<*>) {
Unit::class -> null
else -> mapOf(
"application/json" to MediaType(
schema = ReferenceDefinition(this.getReferenceSlug()),
examples = examples
)
)
}
}

View File

@ -0,0 +1,23 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.metadata.DeleteInfo
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.HeadInfo
import io.bkbn.kompendium.core.metadata.OptionsInfo
import io.bkbn.kompendium.core.metadata.PatchInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.oas.payload.Parameter
interface SpecConfig {
var tags: Set<String>
var parameters: List<Parameter>
var get: GetInfo?
var post: PostInfo?
var put: PutInfo?
var delete: DeleteInfo?
var patch: PatchInfo?
var head: HeadInfo?
var options: OptionsInfo?
var security: Map<String, List<String>>?
}

View File

@ -14,9 +14,13 @@ import io.bkbn.kompendium.core.util.TestModules.singleException
import io.bkbn.kompendium.core.util.TestModules.genericException
import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponse
import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponseMultipleImpls
import io.bkbn.kompendium.core.util.TestModules.gnarlyGenericResponse
import io.bkbn.kompendium.core.util.TestModules.headerParameter
import io.bkbn.kompendium.core.util.TestModules.multipleExceptions
import io.bkbn.kompendium.core.util.TestModules.nestedGenericCollection
import io.bkbn.kompendium.core.util.TestModules.nestedGenericMultipleParamsCollection
import io.bkbn.kompendium.core.util.TestModules.nestedGenericResponse
import io.bkbn.kompendium.core.util.TestModules.nestedTypeName
import io.bkbn.kompendium.core.util.TestModules.nonRequiredParam
import io.bkbn.kompendium.core.util.TestModules.polymorphicException
import io.bkbn.kompendium.core.util.TestModules.notarizedHead
@ -27,6 +31,7 @@ import io.bkbn.kompendium.core.util.TestModules.notarizedPut
import io.bkbn.kompendium.core.util.TestModules.nullableEnumField
import io.bkbn.kompendium.core.util.TestModules.nullableField
import io.bkbn.kompendium.core.util.TestModules.nullableNestedObject
import io.bkbn.kompendium.core.util.TestModules.nullableReference
import io.bkbn.kompendium.core.util.TestModules.polymorphicCollectionResponse
import io.bkbn.kompendium.core.util.TestModules.polymorphicMapResponse
import io.bkbn.kompendium.core.util.TestModules.polymorphicResponse
@ -37,6 +42,7 @@ import io.bkbn.kompendium.core.util.TestModules.returnsList
import io.bkbn.kompendium.core.util.TestModules.rootRoute
import io.bkbn.kompendium.core.util.TestModules.simpleGenericResponse
import io.bkbn.kompendium.core.util.TestModules.simplePathParsing
import io.bkbn.kompendium.core.util.TestModules.simpleRecursive
import io.bkbn.kompendium.core.util.TestModules.trailingSlash
import io.bkbn.kompendium.core.util.TestModules.withOperationId
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
@ -157,6 +163,15 @@ class KompendiumTest : DescribeSpec({
it("Can handle an absolutely psycho inheritance test") {
openApiTestAllSerializers("T0033__crazy_polymorphic_example.json") { genericPolymorphicResponseMultipleImpls() }
}
it("Can support nested generic collections") {
openApiTestAllSerializers("T0039__nested_generic_collection.json") { nestedGenericCollection() }
}
it("Can support nested generics with multiple type parameters") {
openApiTestAllSerializers("T0040__nested_generic_multiple_type_params.json") { nestedGenericMultipleParamsCollection() }
}
it("Can handle a really gnarly generic example") {
openApiTestAllSerializers("T0043__gnarly_generic_example.json") { gnarlyGenericResponse() }
}
}
describe("Miscellaneous") {
xit("Can generate the necessary ReDoc home page") {
@ -174,8 +189,8 @@ class KompendiumTest : DescribeSpec({
xit("Can override field name") {
// TODO Assess strategies here
}
xit("Can serialize a recursive type") {
// TODO openApiTestAllSerializers("simple_recursive.json") { simpleRecursive() }
it("Can serialize a recursive type") {
openApiTestAllSerializers("T0042__simple_recursive.json") { simpleRecursive() }
}
it("Nullable fields do not lead to doom") {
openApiTestAllSerializers("T0036__nullable_fields.json") { nullableNestedObject() }
@ -183,6 +198,12 @@ class KompendiumTest : DescribeSpec({
it("Can have a nullable enum as a member field") {
openApiTestAllSerializers("T0037__nullable_enum_field.json") { nullableEnumField() }
}
it("Can have a nullable reference without impacting base type") {
openApiTestAllSerializers("T0041__nullable_reference.json") { nullableReference() }
}
it("Can handle nested type names") {
openApiTestAllSerializers("T0044__nested_type_name.json") { nestedTypeName() }
}
}
describe("Constraints") {
// TODO Assess strategies here

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.Barzo
import io.bkbn.kompendium.core.fixtures.ColumnSchema
import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.DateTimeString
@ -7,9 +8,14 @@ import io.bkbn.kompendium.core.fixtures.DefaultField
import io.bkbn.kompendium.core.fixtures.ExceptionResponse
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.ManyThings
import io.bkbn.kompendium.core.fixtures.MultiNestedGenerics
import io.bkbn.kompendium.core.fixtures.Nested
import io.bkbn.kompendium.core.fixtures.NullableEnum
import io.bkbn.kompendium.core.fixtures.NullableField
import io.bkbn.kompendium.core.fixtures.Page
import io.bkbn.kompendium.core.fixtures.ProfileUpdateRequest
import io.bkbn.kompendium.core.fixtures.TestCreatedResponse
import io.bkbn.kompendium.core.fixtures.TestNested
@ -561,28 +567,40 @@ object TestModules {
fun Routing.simpleGenericResponse() = basicGetGenerator<Gibbity<String>>()
fun Routing.gnarlyGenericResponse() = basicGetGenerator<Foosy<Barzo<Int>, String>>()
fun Routing.nestedGenericResponse() = basicGetGenerator<Gibbity<Map<String, String>>>()
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.withOperationId() = basicGetGenerator<TestResponse>(operationId = "getThisDude")
fun Routing.nullableNestedObject() = basicGetGenerator<ProfileUpdateRequest>()
fun Routing.nullableEnumField() = basicGetGenerator<NullableEnum>()
fun Routing.nullableReference() = basicGetGenerator<ManyThings>()
fun Routing.dateTimeString() = basicGetGenerator<DateTimeString>()
fun Routing.headerParameter() = basicGetGenerator<TestResponse>( params = listOf(
fun Routing.headerParameter() = basicGetGenerator<TestResponse>(
params = listOf(
Parameter(
name = "X-User-Email",
`in` = Parameter.Location.header,
schema = TypeDefinition.STRING,
required = true
)
))
)
)
fun Routing.nestedTypeName() = basicGetGenerator<Nested.Response>()
fun Routing.simpleRecursive() = basicGetGenerator<ColumnSchema>()

View File

@ -62,30 +62,7 @@
"type": "null"
},
{
"type": "object",
"properties": {
"isPrivate": {
"oneOf": [
{
"type": "null"
},
{
"type": "boolean"
}
]
},
"otherThing": {
"oneOf": [
{
"type": "null"
},
{
"type": "string"
}
]
}
},
"required": []
"$ref": "#/components/schemas/ProfileMetadataUpdateRequest"
}
]
},
@ -112,6 +89,39 @@
}
},
"required": []
},
"ProfileMetadataUpdateRequest": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"isPrivate": {
"oneOf": [
{
"type": "null"
},
{
"type": "boolean"
}
]
},
"otherThing": {
"oneOf": [
{
"type": "null"
},
{
"type": "string"
}
]
}
},
"required": []
}
]
}
},
"securitySchemes": {}

View File

@ -1,5 +1,6 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
@ -26,72 +27,71 @@
}
],
"paths": {
"/test/test/{name}": {
"post": {
"/": {
"get": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"requestBody": {
"description": "Cool stuff",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleRequest"
}
}
},
"required": true
},
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A successful endeavor",
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
"$ref": "#/components/schemas/Page-Int"
}
}
}
}
},
"deprecated": false
}
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"SimpleRequest": {
"Page-Int": {
"type": "object",
"properties": {
"input": {
"type": "string"
"content": {
"items": {
"type": "number",
"format": "int32"
},
"type": "array"
},
"number": {
"type": "number",
"format": "int32"
},
"numberOfElements": {
"type": "number",
"format": "int32"
},
"size": {
"type": "number",
"format": "int32"
},
"totalElements": {
"type": "number",
"format": "int64"
},
"totalPages": {
"type": "number",
"format": "int32"
}
},
"required": [
"input"
],
"type": "object"
},
"SimpleResponse": {
"properties": {
"result": {
"type": "boolean"
}
},
"required": [
"result"
],
"type": "object"
"content",
"number",
"numberOfElements",
"size",
"totalElements",
"totalPages"
]
}
},
"securitySchemes": {}

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/MultiNestedGenerics-String-ComplexRequest"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"CrazyItem": {
"type": "object",
"properties": {
"enumeration": {
"enum": [
"ONE",
"TWO"
]
}
},
"required": [
"enumeration"
]
},
"NestedComplexItem": {
"type": "object",
"properties": {
"alias": {
"additionalProperties": {
"$ref": "#/components/schemas/CrazyItem"
},
"type": "object"
},
"name": {
"type": "string"
}
},
"required": [
"alias",
"name"
]
},
"ComplexRequest": {
"type": "object",
"properties": {
"amazingField": {
"type": "string"
},
"org": {
"type": "string"
},
"tables": {
"items": {
"$ref": "#/components/schemas/NestedComplexItem"
},
"type": "array"
}
},
"required": [
"amazingField",
"org",
"tables"
]
},
"MultiNestedGenerics-String-ComplexRequest": {
"type": "object",
"properties": {
"content": {
"additionalProperties": {
"$ref": "#/components/schemas/ComplexRequest"
},
"type": "object"
}
},
"required": [
"content"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -1,5 +1,6 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
@ -26,72 +27,68 @@
}
],
"paths": {
"/test/test/{name}": {
"put": {
"/": {
"get": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"requestBody": {
"description": "Cool stuff",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleRequest"
}
}
},
"required": true
},
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A successful endeavor",
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
"$ref": "#/components/schemas/ManyThings"
}
}
}
}
},
"deprecated": false
}
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"SimpleRequest": {
"Something": {
"type": "object",
"properties": {
"input": {
"a": {
"type": "string"
},
"b": {
"type": "number",
"format": "int32"
}
},
"required": [
"input"
],
"type": "object"
"a",
"b"
]
},
"SimpleResponse": {
"ManyThings": {
"type": "object",
"properties": {
"result": {
"type": "boolean"
"someA": {
"$ref": "#/components/schemas/Something"
},
"someB": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Something"
}
]
}
},
"required": [
"result"
],
"type": "object"
"someA"
]
}
},
"securitySchemes": {}

View File

@ -1,5 +1,6 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
@ -26,59 +27,64 @@
}
],
"paths": {
"/test/test/{name}/nesty": {
"delete": {
"/": {
"get": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "isCool",
"in": "query",
"schema": {
"type": "boolean"
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A successful endeavor",
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
"$ref": "#/components/schemas/ColumnSchema"
}
}
}
}
},
"deprecated": false
}
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"SimpleResponse": {
"ColumnSchema": {
"type": "object",
"properties": {
"result": {
"type": "boolean"
"description": {
"type": "string"
},
"mode": {
"enum": [
"NULLABLE",
"REQUIRED",
"REPEATED"
]
},
"name": {
"type": "string"
},
"subColumns": {
"items": {
"$ref": "#/components/schemas/ColumnSchema"
},
"type": "array"
},
"type": {
"type": "string"
}
},
"required": [
"result"
],
"type": "object"
"description",
"mode",
"name",
"type"
]
}
},
"securitySchemes": {}

View File

@ -1,5 +1,6 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
@ -26,59 +27,61 @@
}
],
"paths": {
"/test/test/{name}/nesty": {
"/": {
"get": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "isCool",
"in": "query",
"schema": {
"type": "boolean"
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A successful endeavor",
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
"$ref": "#/components/schemas/Foosy-Barzo-String"
}
}
}
}
},
"deprecated": false
}
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"SimpleResponse": {
"Foosy-Barzo-String": {
"type": "object",
"properties": {
"otherThing": {
"items": {
"type": "string"
},
"type": "array"
},
"test": {
"$ref": "#/components/schemas/Barzo-Int"
}
},
"required": [
"otherThing",
"test"
]
},
"Barzo-Int": {
"type": "object",
"properties": {
"result": {
"type": "boolean"
"type": "number",
"format": "int32"
}
},
"required": [
"result"
],
"type": "object"
]
}
},
"securitySchemes": {}

View File

@ -1,5 +1,6 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
@ -26,50 +27,42 @@
}
],
"paths": {
"/test/test/{name}": {
"/": {
"get": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A successful endeavor",
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
"$ref": "#/components/schemas/NestedResponse"
}
}
}
}
},
"deprecated": false
}
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"SimpleResponse": {
"NestedResponse": {
"type": "object",
"properties": {
"result": {
"idk": {
"type": "boolean"
}
},
"required": [
"result"
],
"type": "object"
"idk"
]
}
},
"securitySchemes": {}

View File

@ -23,6 +23,7 @@ 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.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.Routing
import io.ktor.server.testing.ApplicationTestBuilder
@ -63,17 +64,19 @@ object TestHelpers {
fun openApiTestAllSerializers(
snapshotName: String,
customTypes: Map<KType, JsonSchema> = emptyMap(),
applicationSetup: Application.() -> Unit = { },
routeUnderTest: Routing.() -> Unit
) {
openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, customTypes)
openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, customTypes)
openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, customTypes)
openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, applicationSetup, customTypes)
openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, applicationSetup, customTypes)
openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, applicationSetup, customTypes)
}
private fun openApiTest(
snapshotName: String,
serializer: SupportedSerializer,
routeUnderTest: Routing.() -> Unit,
applicationSetup: Application.() -> Unit,
typeOverrides: Map<KType, JsonSchema> = emptyMap()
) = testApplication {
install(NotarizedApplication()) {
@ -95,6 +98,7 @@ object TestHelpers {
}
}
}
application(applicationSetup)
routing {
redoc()
routeUnderTest()

View File

@ -118,3 +118,31 @@ public data class ProfileMetadataUpdateRequest(
public val isPrivate: Boolean?,
public val otherThing: String?
)
data class Page<T>(
val content: List<T>,
val totalElements: Long,
val totalPages: Int,
val numberOfElements: Int,
val number: Int,
val size: Int
)
data class MultiNestedGenerics<T, E>(
val content: Map<T, E>
)
data class Something(val a: String, val b: Int)
data class ManyThings(
val someA: Something,
val someB: Something?
)
data class Foosy<T, K>(val test: T, val otherThing: List<K>)
data class Barzo<G>(val result: G)
object Nested {
@Serializable
data class Response(val idk: Boolean)
}

View File

@ -1 +1 @@
<meta http-equiv="refresh" content="0; url=./2.3.4" />
<meta http-equiv="refresh" content="0; url=./3.0.0-beta-SNAPSHOT" />

View File

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

3
json-schema/Module.md Normal file
View File

@ -0,0 +1,3 @@
# Module kompendium-json-schema
This module handles converting Kotlin data classes to compliant [JsonSchema](https://json-schema.org)

View File

@ -14,6 +14,7 @@ import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.typeOf
import java.util.UUID
object SchemaGenerator {
inline fun <reified T : Any?> fromTypeToSchema(cache: MutableMap<String, JsonSchema> = mutableMapOf()) =
@ -37,6 +38,7 @@ object SchemaGenerator {
Float::class -> checkForNull(type, TypeDefinition.FLOAT)
String::class -> checkForNull(type, TypeDefinition.STRING)
Boolean::class -> checkForNull(type, TypeDefinition.BOOLEAN)
UUID::class -> checkForNull(type, TypeDefinition.UUID)
else -> when {
clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz)
clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache)

View File

@ -40,6 +40,11 @@ data class TypeDefinition(
type = "string"
)
val UUID = TypeDefinition(
type = "string",
format = "uuid"
)
val BOOLEAN = TypeDefinition(
type = "boolean"
)

View File

@ -13,21 +13,32 @@ import kotlin.reflect.KProperty
import kotlin.reflect.KType
import kotlin.reflect.KTypeParameter
import kotlin.reflect.KTypeProjection
import kotlin.reflect.full.createType
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
object SimpleObjectHandler {
fun handle(type: KType, clazz: KClass<*>, cache: MutableMap<String, JsonSchema>): JsonSchema {
// cache[type.getSimpleSlug()] = ReferenceDefinition("RECURSION_PLACEHOLDER")
cache[type.getSimpleSlug()] = ReferenceDefinition(type.getReferenceSlug())
val typeMap = clazz.typeParameters.zip(type.arguments).toMap()
val props = clazz.memberProperties.associate { prop ->
val schema = when (typeMap.containsKey(prop.returnType.classifier)) {
val schema = when (prop.needsToInjectGenerics(typeMap)) {
true -> handleNestedGenerics(typeMap, prop, cache)
false -> when (typeMap.containsKey(prop.returnType.classifier)) {
true -> handleGenericProperty(prop, typeMap, cache)
false -> handleProperty(prop, cache)
}
}
prop.name to schema
val nullCheckSchema = when (prop.returnType.isMarkedNullable && !schema.isNullable()) {
true -> OneOfDefinition(NullableDefinition(), schema)
false -> schema
}
prop.name to nullCheckSchema
}
val required = clazz.memberProperties.filterNot { prop -> prop.returnType.isMarkedNullable }
@ -48,6 +59,34 @@ object SimpleObjectHandler {
}
}
private fun KProperty<*>.needsToInjectGenerics(
typeMap: Map<KTypeParameter, KTypeProjection>
): Boolean {
val typeSymbols = returnType.arguments.map { it.type.toString() }
return typeMap.any { (k, _) -> typeSymbols.contains(k.name) }
}
private fun handleNestedGenerics(
typeMap: Map<KTypeParameter, KTypeProjection>,
prop: KProperty<*>,
cache: MutableMap<String, JsonSchema>
): JsonSchema {
val propClass = prop.returnType.classifier as KClass<*>
val types = prop.returnType.arguments.map {
val typeSymbol = it.type.toString()
typeMap.filterKeys { k -> k.name == typeSymbol }.values.first()
}
val constructedType = propClass.createType(types)
return SchemaGenerator.fromTypeToSchema(constructedType, cache).let {
if (it.isOrContainsObjectDef()) {
cache[constructedType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
} else {
it
}
}
}
private fun handleGenericProperty(
prop: KProperty<*>,
typeMap: Map<KTypeParameter, KTypeProjection>,
@ -55,9 +94,9 @@ object SimpleObjectHandler {
): JsonSchema {
val type = typeMap[prop.returnType.classifier]?.type!!
return SchemaGenerator.fromTypeToSchema(type, cache).let {
if (it is TypeDefinition && it.type == "object") {
if (it.isOrContainsObjectDef()) {
cache[type.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
ReferenceDefinition(type.getReferenceSlug())
} else {
it
}
@ -66,11 +105,19 @@ object SimpleObjectHandler {
private fun handleProperty(prop: KProperty<*>, cache: MutableMap<String, JsonSchema>): JsonSchema =
SchemaGenerator.fromTypeToSchema(prop.returnType, cache).let {
if (it is TypeDefinition && it.type == "object") {
if (it.isOrContainsObjectDef()) {
cache[prop.returnType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
} else {
it
}
}
private fun JsonSchema.isOrContainsObjectDef(): Boolean {
val isTypeDef = this is TypeDefinition && type == "object"
val isTypeDefOneOf = this is OneOfDefinition && this.oneOf.any { js -> js is TypeDefinition && js.type == "object" }
return isTypeDef || isTypeDefOneOf
}
private fun JsonSchema.isNullable(): Boolean = this is OneOfDefinition && this.oneOf.any{ it is NullableDefinition }
}

View File

@ -9,21 +9,26 @@ object Helpers {
fun KType.getSimpleSlug(): String = when {
this.arguments.isNotEmpty() -> genericNameAdapter(this, classifier as KClass<*>)
else -> (classifier as KClass<*>).simpleName ?: error("Could not determine simple name for $this")
else -> (classifier as KClass<*>).kompendiumSlug() ?: error("Could not determine simple name for $this")
}
fun KType.getReferenceSlug(): String = when {
arguments.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, classifier as KClass<*>)}"
else -> "$COMPONENT_SLUG/${(classifier as KClass<*>).simpleName}"
else -> "$COMPONENT_SLUG/${(classifier as KClass<*>).kompendiumSlug()}"
}
@Suppress("ReturnCount")
private fun KClass<*>.kompendiumSlug(): String? {
if (java.packageName == "java.lang") return simpleName
if (java.packageName == "java.util") return simpleName
val pkg = java.packageName
return qualifiedName?.replace(pkg, "")?.replace(".", "")
}
/**
* Adapts a class with type parameters into a reference friendly string
*/
private fun genericNameAdapter(type: KType, clazz: KClass<*>): String {
val classNames = type.arguments
.map { it.type?.classifier as KClass<*> }
.map { it.simpleName }
return classNames.joinToString(separator = "-", prefix = "${clazz.simpleName}-")
.map { it.kompendiumSlug() }
return classNames.joinToString(separator = "-", prefix = "${clazz.kompendiumSlug()}-")
}
}

View File

@ -12,6 +12,7 @@ import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import kotlinx.serialization.json.Json
import java.util.UUID
class SchemaGeneratorTest : DescribeSpec({
describe("Scalars") {
@ -24,6 +25,9 @@ class SchemaGeneratorTest : DescribeSpec({
it("Can generate the schema for a String") {
jsonSchemaTest<String>("T0003__scalar_string.json")
}
it("Can generate the schema for a UUID") {
jsonSchemaTest<UUID>("T0017__scalar_uuid.json")
}
}
describe("Objects") {
it("Can generate the schema for a simple object") {

View File

@ -0,0 +1,4 @@
{
"type": "string",
"format": "uuid"
}

View File

@ -1,4 +1,3 @@
# Module kompendium-locations
Adds support for Ktor [Locations](https://ktor.io/docs/locations.html) API. Any notarized location _must_ be provided
with a `TParam` annotated with `@Location`. Nested Locations are supported
Adds support for Ktor [Locations](https://ktor.io/docs/locations.html) API.

View File

@ -1,84 +0,0 @@
package io.bkbn.kompendium.locations
//import io.bkbn.kompendium.annotations.Param
//import io.bkbn.kompendium.core.Kompendium
//import io.bkbn.kompendium.core.metadata.method.MethodInfo
//import io.bkbn.kompendium.core.parser.IMethodParser
//import io.bkbn.kompendium.oas.path.Path
//import io.bkbn.kompendium.oas.path.PathOperation
//import io.bkbn.kompendium.oas.payload.Parameter
//import io.ktor.application.feature
//import io.ktor.locations.KtorExperimentalLocationsAPI
//import io.ktor.locations.Location
//import io.ktor.routing.Route
//import io.ktor.routing.application
//import kotlin.reflect.KAnnotatedElement
//import kotlin.reflect.KClass
//import kotlin.reflect.KClassifier
//import kotlin.reflect.KType
//import kotlin.reflect.full.createType
//import kotlin.reflect.full.findAnnotation
//import kotlin.reflect.full.hasAnnotation
//import kotlin.reflect.full.memberProperties
//
//@OptIn(KtorExperimentalLocationsAPI::class)
//object LocationMethodParser : IMethodParser {
// override fun KType.toParameterSpec(info: MethodInfo<*, *>, feature: Kompendium): List<Parameter> {
// val clazzList = determineLocationParents(classifier!!)
// return clazzList.associateWith { it.memberProperties }
// .flatMap { (clazz, memberProperties) -> memberProperties.associateWith { clazz }.toList() }
// .filter { (prop, _) -> prop.hasAnnotation<Param>() }
// .map { (prop, clazz) -> prop.toParameter(info, clazz.createType(), clazz, feature) }
// }
//
// private fun determineLocationParents(classifier: KClassifier): List<KClass<*>> {
// var clazz: KClass<*>? = classifier as KClass<*>
// val clazzList = mutableListOf<KClass<*>>()
// while (clazz != null) {
// clazzList.add(clazz)
// clazz = getLocationParent(clazz)
// }
// return clazzList
// }
//
// private fun getLocationParent(clazz: KClass<*>): KClass<*>? {
// val parent = clazz.memberProperties
// .find { (it.returnType.classifier as KAnnotatedElement).hasAnnotation<Location>() }
// return parent?.returnType?.classifier as? KClass<*>
// }
//
// fun KClass<*>.calculateLocationPath(suffix: String = ""): String {
// val locationAnnotation = this.findAnnotation<Location>()
// require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" }
// val parent = this.java.declaringClass?.kotlin?.takeIf { it.hasAnnotation<Location>() }
// val newSuffix = locationAnnotation.path.plus(suffix)
// return when (parent) {
// null -> newSuffix
// else -> parent.calculateLocationPath(newSuffix)
// }
// }
//
// inline fun <reified TParam : Any> processBaseInfo(
// paramType: KType,
// requestType: KType,
// responseType: KType,
// info: MethodInfo<*, *>,
// route: Route
// ): LocationBaseInfo {
// val locationAnnotation = TParam::class.findAnnotation<Location>()
// require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" }
// val path = route.calculateRoutePath()
// val locationPath = TParam::class.calculateLocationPath()
// val pathWithLocation = path.plus(locationPath)
// val feature = route.application.feature(Kompendium)
// feature.config.spec.paths.getOrPut(pathWithLocation) { Path() }
// val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
// return LocationBaseInfo(baseInfo, feature, pathWithLocation)
// }
//
// data class LocationBaseInfo(
// val op: PathOperation,
// val feature: Kompendium,
// val path: String
// )
//}

View File

@ -1,110 +0,0 @@
package io.bkbn.kompendium.locations
//import io.bkbn.kompendium.core.KompendiumPreFlight.methodNotarizationPreFlight
//import io.bkbn.kompendium.core.metadata.method.DeleteInfo
//import io.bkbn.kompendium.core.metadata.method.GetInfo
//import io.bkbn.kompendium.core.metadata.method.PostInfo
//import io.bkbn.kompendium.core.metadata.method.PutInfo
//import io.bkbn.kompendium.oas.path.PathOperation
//import io.ktor.application.ApplicationCall
//import io.ktor.http.HttpMethod
//import io.ktor.locations.KtorExperimentalLocationsAPI
//import io.ktor.locations.handle
//import io.ktor.locations.location
//import io.ktor.routing.Route
//import io.ktor.routing.method
//import io.ktor.util.pipeline.PipelineContext
//
///**
// * This version of notarized routes leverages the Ktor [io.ktor.locations.Locations] plugin to provide type safe access
// * to all path and query parameters.
// */
//@KtorExperimentalLocationsAPI
//object NotarizedLocation {
//
// /**
// * Notarization for an HTTP GET request leveraging the Ktor [io.ktor.locations.Locations] plugin
// * @param TParam The class containing all parameter fields.
// * Each field must be annotated with @[io.bkbn.kompendium.annotations.Param].
// * Additionally, the class must be annotated with @[io.ktor.locations.Location].
// * @param TResp Class detailing the expected API response
// * @param info Route metadata
// * @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
// */
// inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
// info: GetInfo<TParam, TResp>,
// postProcess: (PathOperation) -> PathOperation = { p -> p },
// noinline body: suspend PipelineContext<Unit, ApplicationCall>.(TParam) -> Unit
// ): Route = methodNotarizationPreFlight<TParam, Unit, TResp>() { paramType, requestType, responseType ->
// val lbi = LocationMethodParser.processBaseInfo<TParam>(paramType, requestType, responseType, info, this)
// lbi.feature.config.spec.paths[lbi.path]?.get = postProcess(lbi.op)
// return location(TParam::class) {
// method(HttpMethod.Get) { handle(body) }
// }
// }
//
// /**
// * Notarization for an HTTP POST request leveraging the Ktor [io.ktor.locations.Locations] plugin
// * @param TParam The class containing all parameter fields.
// * Each field must be annotated with @[io.bkbn.kompendium.annotations.Param]
// * Additionally, the class must be annotated with @[io.ktor.locations.Location].
// * @param TReq Class detailing the expected API request body
// * @param TResp Class detailing the expected API response
// * @param info Route metadata
// * @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
// */
// inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
// info: PostInfo<TParam, TReq, TResp>,
// postProcess: (PathOperation) -> PathOperation = { p -> p },
// noinline body: suspend PipelineContext<Unit, ApplicationCall>.(TParam) -> Unit
// ): Route = methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
// val lbi = LocationMethodParser.processBaseInfo<TParam>(paramType, requestType, responseType, info, this)
// lbi.feature.config.spec.paths[lbi.path]?.post = postProcess(lbi.op)
// return location(TParam::class) {
// method(HttpMethod.Post) { handle(body) }
// }
// }
//
// /**
// * Notarization for an HTTP Delete request leveraging the Ktor [io.ktor.locations.Locations] plugin
// * @param TParam The class containing all parameter fields.
// * Each field must be annotated with @[io.bkbn.kompendium.annotations.Param]
// * Additionally, the class must be annotated with @[io.ktor.locations.Location].
// * @param TReq Class detailing the expected API request body
// * @param TResp Class detailing the expected API response
// * @param info Route metadata
// * @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
// */
// inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
// info: PutInfo<TParam, TReq, TResp>,
// postProcess: (PathOperation) -> PathOperation = { p -> p },
// noinline body: suspend PipelineContext<Unit, ApplicationCall>.(TParam) -> Unit
// ): Route = methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
// val lbi = LocationMethodParser.processBaseInfo<TParam>(paramType, requestType, responseType, info, this)
// lbi.feature.config.spec.paths[lbi.path]?.put = postProcess(lbi.op)
// return location(TParam::class) {
// method(HttpMethod.Put) { handle(body) }
// }
// }
//
// /**
// * Notarization for an HTTP POST request leveraging the Ktor [io.ktor.locations.Locations] plugin
// * @param TParam The class containing all parameter fields.
// * Each field must be annotated with @[io.bkbn.kompendium.annotations.Param]
// * Additionally, the class must be annotated with @[io.ktor.locations.Location].
// * @param TResp Class detailing the expected API response
// * @param info Route metadata
// * @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
// */
// inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
// info: DeleteInfo<TParam, TResp>,
// postProcess: (PathOperation) -> PathOperation = { p -> p },
// noinline body: suspend PipelineContext<Unit, ApplicationCall>.(TParam) -> Unit
// ): Route = methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
// val lbi = LocationMethodParser.processBaseInfo<TParam>(paramType, requestType, responseType, info, this)
// lbi.feature.config.spec.paths[lbi.path]?.delete = postProcess(lbi.op)
// return location(TParam::class) {
// method(HttpMethod.Delete) { handle(body) }
// }
// }
//}

View File

@ -0,0 +1,79 @@
package io.bkbn.kompendium.locations
import io.bkbn.kompendium.core.attribute.KompendiumAttributes
import io.bkbn.kompendium.core.metadata.DeleteInfo
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.HeadInfo
import io.bkbn.kompendium.core.metadata.OptionsInfo
import io.bkbn.kompendium.core.metadata.PatchInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.util.Helpers.addToSpec
import io.bkbn.kompendium.core.util.SpecConfig
import io.bkbn.kompendium.oas.path.Path
import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.locations.KtorExperimentalLocationsAPI
import io.ktor.server.locations.Location
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties
object NotarizedLocations {
data class LocationMetadata(
override var tags: Set<String> = emptySet(),
override var parameters: List<Parameter> = emptyList(),
override var get: GetInfo? = null,
override var post: PostInfo? = null,
override var put: PutInfo? = null,
override var delete: DeleteInfo? = null,
override var patch: PatchInfo? = null,
override var head: HeadInfo? = null,
override var options: OptionsInfo? = null,
override var security: Map<String, List<String>>? = null,
) : SpecConfig
class Config {
lateinit var locations: Map<KClass<*>, LocationMetadata>
}
operator fun invoke() = createApplicationPlugin(
name = "NotarizedLocations",
createConfiguration = ::Config
) {
val spec = application.attributes[KompendiumAttributes.openApiSpec]
pluginConfig.locations.forEach { (k, v) ->
val path = Path()
path.parameters = v.parameters
v.get?.addToSpec(path, spec, v)
v.delete?.addToSpec(path, spec, v)
v.head?.addToSpec(path, spec, v)
v.options?.addToSpec(path, spec, v)
v.post?.addToSpec(path, spec, v)
v.put?.addToSpec(path, spec, v)
v.patch?.addToSpec(path, spec, v)
val location = k.getLocationFromClass()
spec.paths[location] = path
}
}
@OptIn(KtorExperimentalLocationsAPI::class)
private fun KClass<*>.getLocationFromClass(): String {
// todo if parent
val location = findAnnotation<Location>()
?: error("Cannot notarize a location without annotating with @Location")
val path = location.path
val parent = memberProperties.map { it.returnType.classifier as KClass<*> }.find { it.hasAnnotation<Location>() }
return if (parent == null) {
path
} else {
parent.getLocationFromClass() + path
}
}
}

View File

@ -1,82 +1,119 @@
package io.bkbn.kompendium.locations
//import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
//import io.bkbn.kompendium.locations.util.locationsConfig
//import io.bkbn.kompendium.locations.util.notarizedDeleteNestedLocation
//import io.bkbn.kompendium.locations.util.notarizedDeleteSimpleLocation
//import io.bkbn.kompendium.locations.util.notarizedGetNestedLocation
//import io.bkbn.kompendium.locations.util.notarizedGetNestedLocationFromNonLocationClass
//import io.bkbn.kompendium.locations.util.notarizedGetSimpleLocation
//import io.bkbn.kompendium.locations.util.notarizedPostNestedLocation
//import io.bkbn.kompendium.locations.util.notarizedPostSimpleLocation
//import io.bkbn.kompendium.locations.util.notarizedPutNestedLocation
//import io.bkbn.kompendium.locations.util.notarizedPutSimpleLocation
//import io.kotest.core.spec.style.DescribeSpec
//
//class KompendiumLocationsTest : DescribeSpec({
// describe("Locations") {
// it("Can notarize a get request with a simple location") {
// // act
// openApiTestAllSerializers("notarized_get_simple_location.json") {
// locationsConfig()
// notarizedGetSimpleLocation()
// }
// }
// it("Can notarize a get request with a nested location") {
// // act
// openApiTestAllSerializers("notarized_get_nested_location.json") {
// locationsConfig()
// notarizedGetNestedLocation()
// }
// }
// it("Can notarize a post with a simple location") {
// // act
// openApiTestAllSerializers("notarized_post_simple_location.json") {
// locationsConfig()
// notarizedPostSimpleLocation()
// }
// }
// it("Can notarize a post with a nested location") {
// // act
// openApiTestAllSerializers("notarized_post_nested_location.json") {
// locationsConfig()
// notarizedPostNestedLocation()
// }
// }
// it("Can notarize a put with a simple location") {
// // act
// openApiTestAllSerializers("notarized_put_simple_location.json") {
// locationsConfig()
// notarizedPutSimpleLocation()
// }
// }
// it("Can notarize a put with a nested location") {
// // act
// openApiTestAllSerializers("notarized_put_nested_location.json") {
// locationsConfig()
// notarizedPutNestedLocation()
// }
// }
// it("Can notarize a delete with a simple location") {
// // act
// openApiTestAllSerializers("notarized_delete_simple_location.json") {
// locationsConfig()
// notarizedDeleteSimpleLocation()
// }
// }
// it("Can notarize a delete with a nested location") {
// // act
// openApiTestAllSerializers("notarized_delete_nested_location.json") {
// locationsConfig()
// notarizedDeleteNestedLocation()
// }
// }
// it("Can notarize a get with a nested location nested in a non-location class") {
// // act
// openApiTestAllSerializers("notarized_get_nested_location_from_non_location_class.json") {
// locationsConfig()
// notarizedGetNestedLocationFromNonLocationClass()
// }
// }
// }
//})
import Listing
import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.kotest.core.spec.style.DescribeSpec
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.locations.Locations
import io.ktor.server.locations.get
import io.ktor.server.response.respondText
class KompendiumLocationsTest : DescribeSpec({
describe("Location Tests") {
it("Can notarize a simple location") {
openApiTestAllSerializers(
snapshotName = "T0001__simple_location.json",
applicationSetup = {
install(Locations)
install(NotarizedLocations()) {
locations = mapOf(
Listing::class to NotarizedLocations.LocationMetadata(
parameters = listOf(
Parameter(
name = "name",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
),
Parameter(
name = "page",
`in` = Parameter.Location.path,
schema = TypeDefinition.INT
)
),
get = GetInfo.builder {
summary("Location")
description("example location")
response {
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
description("does great things")
}
}
),
)
}
}
) {
get<Listing> { listing ->
call.respondText("Listing ${listing.name}, page ${listing.page}")
}
}
}
it("Can notarize nested locations") {
openApiTestAllSerializers(
snapshotName = "T0002__nested_locations.json",
applicationSetup = {
install(Locations)
install(NotarizedLocations()) {
locations = mapOf(
Type.Edit::class to NotarizedLocations.LocationMetadata(
parameters = listOf(
Parameter(
name = "name",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
)
),
get = GetInfo.builder {
summary("Edit")
description("example location")
response {
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
description("does great things")
}
}
),
Type.Other::class to NotarizedLocations.LocationMetadata(
parameters = listOf(
Parameter(
name = "name",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
),
Parameter(
name = "page",
`in` = Parameter.Location.path,
schema = TypeDefinition.INT
)
),
get = GetInfo.builder {
summary("Other")
description("example location")
response {
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
description("does great things")
}
}
),
)
}
}
) {
get<Type.Edit> { edit ->
call.respondText("Listing ${edit.parent.name}")
}
get<Type.Other> { other ->
call.respondText("Listing ${other.parent.name}, page ${other.page}")
}
}
}
}
})

View File

@ -1,22 +1,12 @@
package io.bkbn.kompendium.locations.util
import io.ktor.server.locations.Location
//import io.bkbn.kompendium.annotations.Param
//import io.bkbn.kompendium.annotations.ParamType
//import io.ktor.locations.Location
//
//@Location("/test/{name}")
//data class SimpleLoc(@Param(ParamType.PATH) val name: String) {
// @Location("/nesty")
// data class NestedLoc(@Param(ParamType.QUERY) val isCool: Boolean, val parent: SimpleLoc)
//}
//
//object NonLocationObject {
// @Location("/test/{name}")
// data class SimpleLoc(@Param(ParamType.PATH) val name: String) {
// @Location("/nesty")
// data class NestedLoc(@Param(ParamType.QUERY) val isCool: Boolean, val parent: SimpleLoc)
// }
//}
//
//data class SimpleResponse(val result: Boolean)
//data class SimpleRequest(val input: String)
@Location("/list/{name}/page/{page}")
data class Listing(val name: String, val page: Int)
@Location("/type/{name}")
data class Type(val name: String) {
@Location("/edit")
data class Edit(val parent: Type)
@Location("/other/{page}")
data class Other(val parent: Type, val page: Int)
}

View File

@ -1,107 +0,0 @@
package io.bkbn.kompendium.locations.util
//import io.bkbn.kompendium.locations.NotarizedLocation.notarizedDelete
//import io.bkbn.kompendium.locations.NotarizedLocation.notarizedGet
//import io.bkbn.kompendium.locations.NotarizedLocation.notarizedPost
//import io.bkbn.kompendium.locations.NotarizedLocation.notarizedPut
//import io.ktor.application.Application
//import io.ktor.application.call
//import io.ktor.application.install
//import io.ktor.locations.Locations
//import io.ktor.response.respondText
//import io.ktor.routing.route
//import io.ktor.routing.routing
//
//fun Application.locationsConfig() {
// install(Locations)
//}
//
//fun Application.notarizedGetSimpleLocation() {
// routing {
// route("/test") {
// notarizedGet(TestResponseInfo.testGetSimpleLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedGetNestedLocation() {
// routing {
// route("/test") {
// notarizedGet(TestResponseInfo.testGetNestedLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedPostSimpleLocation() {
// routing {
// route("/test") {
// notarizedPost(TestResponseInfo.testPostSimpleLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedPostNestedLocation() {
// routing {
// route("/test") {
// notarizedPost(TestResponseInfo.testPostNestedLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedPutSimpleLocation() {
// routing {
// route("/test") {
// notarizedPut(TestResponseInfo.testPutSimpleLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedPutNestedLocation() {
// routing {
// route("/test") {
// notarizedPut(TestResponseInfo.testPutNestedLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedDeleteSimpleLocation() {
// routing {
// route("/test") {
// notarizedDelete(TestResponseInfo.testDeleteSimpleLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedDeleteNestedLocation() {
// routing {
// route("/test") {
// notarizedDelete(TestResponseInfo.testDeleteNestedLocation) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}
//
//fun Application.notarizedGetNestedLocationFromNonLocationClass() {
// routing {
// route("/test") {
// notarizedGet(TestResponseInfo.testGetNestedLocationFromNonLocationClass) {
// call.respondText { "hey dude ‼️ congratz on the get request" }
// }
// }
// }
//}

View File

@ -1,97 +0,0 @@
package io.bkbn.kompendium.locations.util
//import io.bkbn.kompendium.core.legacy.metadata.RequestInfo
//import io.bkbn.kompendium.core.legacy.metadata.ResponseInfo
//import io.bkbn.kompendium.core.legacy.metadata.method.DeleteInfo
//import io.bkbn.kompendium.core.legacy.metadata.method.GetInfo
//import io.bkbn.kompendium.core.legacy.metadata.method.PostInfo
//import io.bkbn.kompendium.core.legacy.metadata.method.PutInfo
//import io.ktor.http.HttpStatusCode
//
//object TestResponseInfo {
// val testGetSimpleLocation = GetInfo<SimpleLoc, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testPostSimpleLocation = PostInfo<SimpleLoc, SimpleRequest, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// requestInfo = RequestInfo(
// description = "Cool stuff"
// ),
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testPutSimpleLocation = PutInfo<SimpleLoc, SimpleRequest, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// requestInfo = RequestInfo(
// description = "Cool stuff"
// ),
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testDeleteSimpleLocation = DeleteInfo<SimpleLoc, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testGetNestedLocation = GetInfo<SimpleLoc.NestedLoc, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testPostNestedLocation = PostInfo<SimpleLoc.NestedLoc, SimpleRequest, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// requestInfo = RequestInfo(
// description = "Cool stuff"
// ),
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testPutNestedLocation = PutInfo<SimpleLoc.NestedLoc, SimpleRequest, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// requestInfo = RequestInfo(
// description = "Cool stuff"
// ),
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
// val testDeleteNestedLocation = DeleteInfo<SimpleLoc.NestedLoc, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
//
// val testGetNestedLocationFromNonLocationClass = GetInfo<NonLocationObject.SimpleLoc.NestedLoc, SimpleResponse>(
// summary = "Location Test",
// description = "A cool test",
// responseInfo = ResponseInfo(
// status = HttpStatusCode.OK,
// description = "A successful endeavor"
// )
// )
//}

View File

@ -1,5 +1,6 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
@ -26,21 +27,27 @@
}
],
"paths": {
"/test/test/{name}/nesty": {
"/list/{name}/page/{page}": {
"get": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "isCool",
"in": "query",
"summary": "Location",
"description": "example location",
"parameters": [],
"responses": {
"200": {
"description": "does great things",
"content": {
"application/json": {
"schema": {
"type": "boolean"
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"required": true,
"deprecated": false
},
"parameters": [
{
"name": "name",
"in": "path",
@ -49,36 +56,33 @@
},
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "A successful endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
}
}
}
}
},
{
"name": "page",
"in": "path",
"schema": {
"type": "number",
"format": "int32"
},
"required": true,
"deprecated": false
}
]
}
},
"webhooks": {},
"components": {
"schemas": {
"SimpleResponse": {
"TestResponse": {
"type": "object",
"properties": {
"result": {
"type": "boolean"
"c": {
"type": "string"
}
},
"required": [
"result"
],
"type": "object"
"c"
]
}
},
"securitySchemes": {}

View File

@ -0,0 +1,124 @@
{
"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": {
"/type/{name}/edit": {
"get": {
"tags": [],
"summary": "Edit",
"description": "example location",
"parameters": [],
"responses": {
"200": {
"description": "does great things",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": [
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
]
},
"/type/{name}/other/{page}": {
"get": {
"tags": [],
"summary": "Other",
"description": "example location",
"parameters": [],
"responses": {
"200": {
"description": "does great things",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": [
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
},
{
"name": "page",
"in": "path",
"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

@ -1,79 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/test/test/{name}": {
"delete": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "A successful endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
}
}
}
}
},
"deprecated": false
}
}
},
"components": {
"schemas": {
"SimpleResponse": {
"properties": {
"result": {
"type": "boolean"
}
},
"required": [
"result"
],
"type": "object"
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -1,110 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/test/test/{name}/nesty": {
"post": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "isCool",
"in": "query",
"schema": {
"type": "boolean"
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"requestBody": {
"description": "Cool stuff",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "A successful endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
}
}
}
}
},
"deprecated": false
}
}
},
"components": {
"schemas": {
"SimpleRequest": {
"properties": {
"input": {
"type": "string"
}
},
"required": [
"input"
],
"type": "object"
},
"SimpleResponse": {
"properties": {
"result": {
"type": "boolean"
}
},
"required": [
"result"
],
"type": "object"
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -1,110 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/test/test/{name}/nesty": {
"put": {
"tags": [],
"summary": "Location Test",
"description": "A cool test",
"parameters": [
{
"name": "isCool",
"in": "query",
"schema": {
"type": "boolean"
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"requestBody": {
"description": "Cool stuff",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "A successful endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SimpleResponse"
}
}
}
}
},
"deprecated": false
}
}
},
"components": {
"schemas": {
"SimpleRequest": {
"properties": {
"input": {
"type": "string"
}
},
"required": [
"input"
],
"type": "object"
},
"SimpleResponse": {
"properties": {
"result": {
"type": "boolean"
}
},
"required": [
"result"
],
"type": "object"
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -31,7 +31,7 @@ dependencies {
// Logging
implementation("org.apache.logging.log4j:log4j-api-kotlin:1.2.0")
implementation("org.apache.logging.log4j:log4j-api:2.17.2")
implementation("org.apache.logging.log4j:log4j-api:2.18.0")
implementation("org.apache.logging.log4j:log4j-core:2.18.0")
implementation("org.slf4j:slf4j-api:1.7.36")
implementation("org.slf4j:slf4j-simple:1.7.36")

View File

@ -30,10 +30,6 @@ import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
/**
* Application entrypoint. Run this and head on over to `localhost:8081/docs`
* to see a very simple yet beautifully documented API
*/
fun main() {
embeddedServer(
Netty,
@ -43,7 +39,6 @@ fun main() {
}
private fun Application.mainModule() {
// Installs Simple JSON Content Negotiation
install(ContentNegotiation) {
json(Json {
serializersModule = KompendiumSerializersModule.module
@ -75,12 +70,8 @@ private fun Application.mainModule() {
routing {
redoc(pageTitle = "Simple API Docs")
authenticate("basic") {
// This adds ReDoc support at the `/docs` endpoint.
// By default, it will point at the `/openapi.json` created by Kompendium
// Route with a get handler
route("/{id}") {
idDocumentation()
locationDocumentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
@ -89,8 +80,7 @@ private fun Application.mainModule() {
}
}
// Documentation for our route
private fun Route.idDocumentation() {
private fun Route.locationDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(

View File

@ -47,7 +47,7 @@ private fun Application.mainModule() {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
idDocumentation()
documentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
@ -55,7 +55,7 @@ private fun Application.mainModule() {
}
}
private fun Route.idDocumentation() {
private fun Route.documentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(

View File

@ -54,7 +54,7 @@ private fun Application.mainModule() {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
idDocumentation()
locationDocumentation()
get {
call.respond(
HttpStatusCode.OK,
@ -68,7 +68,7 @@ private fun Application.mainModule() {
}
}
private fun Route.idDocumentation() {
private fun Route.locationDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(

View File

@ -13,7 +13,6 @@ import io.bkbn.kompendium.playground.util.Util.baseSpec
import io.ktor.http.HttpStatusCode
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
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
@ -55,7 +54,7 @@ private fun Application.mainModule() {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
idDocumentation()
locationDocumentation()
get {
throw RuntimeException("This wasn't your fault I promise <3")
}
@ -63,7 +62,7 @@ private fun Application.mainModule() {
}
}
private fun Route.idDocumentation() {
private fun Route.locationDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(

View File

@ -43,7 +43,7 @@ private fun Application.mainModule() {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
idDocumentation()
locationDocumentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
@ -51,7 +51,7 @@ private fun Application.mainModule() {
}
}
private fun Route.idDocumentation() {
private fun Route.locationDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(

View File

@ -0,0 +1,116 @@
package io.bkbn.kompendium.playground
import io.bkbn.kompendium.core.attribute.KompendiumAttributes
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.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.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.basic
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.application
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
fun main() {
embeddedServer(
Netty,
port = 8081,
module = Application::mainModule
).start(wait = true)
}
private fun Application.mainModule() {
install(ContentNegotiation) {
json(Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = false
})
}
install(Authentication) {
basic("basic") {
realm = "Ktor Server"
validate { credentials ->
if (credentials.name == credentials.password) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
install(NotarizedApplication()) {
spec = baseSpec.copy(
components = Components(
securitySchemes = mutableMapOf(
"basic" to BasicAuth()
)
)
)
openApiJson = {
authenticate("basic") {
route("/openapi.json") {
get {
call.respond(HttpStatusCode.OK, this@route.application.attributes[KompendiumAttributes.openApiSpec])
}
}
}
}
}
routing {
authenticate("basic") {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
locationDocumentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
}
}
}
}
private fun Route.locationDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(
name = "id",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
)
)
get = GetInfo.builder {
summary("Get user by id")
description("A very neat endpoint!")
security = mapOf(
"basic" to emptyList()
)
response {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Will return whether or not the user is real 😱")
}
}
}
}

View File

@ -46,7 +46,7 @@ private fun Application.mainModule() {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
idDocumentation()
locationDocumentation()
get {
call.respond(HttpStatusCode.OK, ExampleResponse(true))
}
@ -54,7 +54,7 @@ private fun Application.mainModule() {
}
}
private fun Route.idDocumentation() {
private fun Route.locationDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(

View File

@ -0,0 +1,80 @@
package io.bkbn.kompendium.playground
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.locations.NotarizedLocations
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.Listing
import io.bkbn.kompendium.playground.util.Util.baseSpec
import io.ktor.http.HttpStatusCode
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
import io.ktor.server.engine.embeddedServer
import io.ktor.server.locations.Locations
import io.ktor.server.locations.get
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respondText
import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
fun main() {
embeddedServer(
Netty,
port = 8081,
module = Application::mainModule
).start(wait = true)
}
private fun Application.mainModule() {
install(Locations)
install(ContentNegotiation) {
json(Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = false
})
}
install(NotarizedApplication()) {
spec = baseSpec
}
install(NotarizedLocations()) {
locations = mapOf(
Listing::class to NotarizedLocations.LocationMetadata(
parameters = listOf(
Parameter(
name = "name",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
),
Parameter(
name = "page",
`in` = Parameter.Location.path,
schema = TypeDefinition.INT
)
),
get = GetInfo.builder {
summary("Get user by id")
description("A very neat endpoint!")
response {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Will return whether or not the user is real 😱")
}
}
),
)
}
routing {
redoc(pageTitle = "Simple API Docs")
get<Listing> { listing ->
call.respondText("Listing ${listing.name}, page ${listing.page}")
}
}
}

View File

@ -1,5 +1,7 @@
package io.bkbn.kompendium.playground.util
import io.ktor.server.locations.KtorExperimentalLocationsAPI
import io.ktor.server.locations.Location
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@ -14,3 +16,12 @@ data class CustomTypeResponse(
@Serializable
data class ExceptionResponse(val message: String)
@Location("/list/{name}/page/{page}")
data class Listing(val name: String, val page: Int)
@Location("/type/{name}") data class Type(val name: String) {
// In these classes we have to include the `name` property matching the parent.
@Location("/edit") data class Edit(val parent: Type)
@Location("/other/{page}") data class Other(val parent: Type, val page: Int)
}