feat: v2-alpha (#112)
There are still some bugs, still some outstanding features, but I don't want to hold this back any longer, that way I can keep the future PRs much more focused
This commit is contained in:
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
43
.github/workflows/pr_checks.yml
vendored
43
.github/workflows/pr_checks.yml
vendored
@ -5,40 +5,33 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
java-version: '17'
|
||||
- name: Lint
|
||||
uses: burrunan/gradle-cache-action@v1
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Lint using detekt
|
||||
run: ./gradlew detekt
|
||||
gradle-version: wrapper
|
||||
arguments: detekt
|
||||
properties: |
|
||||
org.gradle.vfs.watch=false
|
||||
org.gradle.vfs.verbose=false
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
java-version: '17'
|
||||
- name: Unit Tests
|
||||
uses: burrunan/gradle-cache-action@v1
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Assemble with Gradle
|
||||
run: ./gradlew assemble
|
||||
- name: Run Unit Tests
|
||||
run: ./gradlew test
|
||||
- name: Cache Coverage Results
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ./**/build/reports/jacoco
|
||||
key: ${{ runner.os }}-unit-${{ env.GITHUB_SHA }}
|
||||
gradle-version: wrapper
|
||||
arguments: test koverCollectReports
|
||||
properties: |
|
||||
org.gradle.vfs.watch=false
|
||||
org.gradle.vfs.verbose=false
|
||||
|
36
.github/workflows/publish.yml
vendored
36
.github/workflows/publish.yml
vendored
@ -13,34 +13,14 @@ jobs:
|
||||
- uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
java-version: '17'
|
||||
- name: Publish to GitHub Packages
|
||||
uses: burrunan/gradle-cache-action@v1
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Publish package
|
||||
run: ./gradlew publishAllPublicationsToGithubPackagesRepository
|
||||
gradle-version: wrapper
|
||||
arguments: publishAllPublicationsToGithubPackagesRepository
|
||||
properties: |
|
||||
org.gradle.vfs.watch=false
|
||||
org.gradle.vfs.verbose=false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
code-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Run Unit Tests
|
||||
run: ./gradlew test
|
||||
- name: Publish code coverage to Codacy
|
||||
uses: codacy/codacy-coverage-reporter-action@v1
|
||||
with:
|
||||
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
|
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@ -15,15 +15,16 @@ jobs:
|
||||
- uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
java-version: '17'
|
||||
- name: Publish to GitHub Packages
|
||||
uses: burrunan/gradle-cache-action@v1
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Publish packages to Github
|
||||
run: ./gradlew publishAllPublicationsToGithubPackagesRepository -Prelease=true
|
||||
gradle-version: wrapper
|
||||
arguments: publishAllPublicationsToGithubPackagesRepository
|
||||
properties: |
|
||||
release=true
|
||||
org.gradle.vfs.watch=false
|
||||
org.gradle.vfs.verbose=false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish-to-nexus:
|
||||
@ -33,15 +34,13 @@ jobs:
|
||||
- uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: '11'
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
java-version: '17'
|
||||
- name: Publlish to GithubPackages
|
||||
uses: burrunan/gradle-cache-action@v1
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Publish packages to Github
|
||||
run: ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -Prelease=true
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.SONATYPE_USER }}
|
||||
ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }}
|
||||
gradle-version: wrapper
|
||||
arguments: publishToSonatype closeAndReleaseSonatypeStagingRepository
|
||||
properties: |
|
||||
release=true
|
||||
org.gradle.vfs.watch=false
|
||||
org.gradle.vfs.verbose=false
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,7 +1,5 @@
|
||||
# Ignore Gradle project-specific cache directory
|
||||
.gradle
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
|
||||
.idea
|
||||
dokka
|
||||
wiki
|
||||
|
@ -1,3 +0,0 @@
|
||||
kotlin 1.5.0-M2
|
||||
java openjdk-14.0.1
|
||||
gradle 7.0
|
54
CHANGELOG.md
54
CHANGELOG.md
@ -1,5 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Remove
|
||||
|
||||
---
|
||||
|
||||
## Released
|
||||
|
||||
## [2.0.0-alpha] - January 2nd, 2021
|
||||
### Added
|
||||
- Support for OAuth authentication
|
||||
- Gradle Toolchain feature to ensure match between local JDK and compile target
|
||||
- Dokka integration
|
||||
- Post-processing callback hook
|
||||
- `description` key to KompendiumField
|
||||
- Set of base constraints for simple and formatted types
|
||||
- Ability to document expected unstructured data
|
||||
|
||||
### Changed
|
||||
- `$ref` types are no longer generated, instead all objects are defined explicitly
|
||||
- All OpenAPI domain models moved to a separate module `kompendium-oas`
|
||||
- Moved all files in `kompendium-core` into `io.bkbn.kompendium.core` package from `io.bkbn.kompendium`
|
||||
- Gradle bumped to 7.3.2
|
||||
- Gradle build logic offloaded to Sourdough Plugin
|
||||
- Minimum supported Java version is now 11
|
||||
- Bumped Kotlin to 1.6
|
||||
- Annotations now live in a separate module. (Should not impact end users as module is imported as api dependency by core).
|
||||
- Kotest as the testing framework of choice
|
||||
- Path calculation removed in favor of built-in route toString
|
||||
- Ktor to 1.6.7
|
||||
- Completely reworked authentication and exceptions
|
||||
- MethodInfo now exists in a separate package as a sealed interface, each implementation also has its own file
|
||||
- Kompendium is now a Ktor Plugin!
|
||||
- GitHub Actions now leverage Gradle Wrapper
|
||||
- Dropped Codacy support b/c codacy kinda sucks
|
||||
- Fixed bug where KompendiumField was being completely ignored
|
||||
- Redid playground to serve as a showcase for various functionality
|
||||
- README updates
|
||||
- Refactored `handleComplexType` 🎉
|
||||
- Enabled field descriptions
|
||||
- Dropped Version Catalog
|
||||
- Responses are now a map of _actual_ responses rather than generic payloads
|
||||
- Fixed bug where params with default values were listed as required
|
||||
- Made empty put/post request info opt-in rather than default
|
||||
- Fields are now marked as required when there is no default, and they are non-nullable
|
||||
- `KompendiumField` and 'KompendiumParam' renamed to `Field` and `Param` respectively
|
||||
- Description dropped from `Param`
|
||||
- Dropped unnecessary parameter content scanning method
|
||||
- Fixed bug causing all request bodies to be marked as optional
|
||||
- Dropped ASDF tool manifest
|
||||
|
||||
## [1.11.1] - November 25th, 2021
|
||||
### Added
|
||||
- Documentation showing how to add header names using Kotlin backtick convention
|
||||
|
374
README.md
374
README.md
@ -1,20 +1,24 @@
|
||||
# Kompendium
|
||||
|
||||
[](https://www.codacy.com/gh/bkbnio/kompendium/dashboard?utm_source=github.com&utm_medium=referral&utm_content=bkbnio/kompendium&utm_campaign=Badge_Grade)
|
||||
[](https://www.codacy.com/gh/bkbnio/kompendium/dashboard?utm_source=github.com&utm_medium=referral&utm_content=bkbnio/kompendium&utm_campaign=Badge_Coverage)
|
||||
[](https://search.maven.org/search?q=io.bkbn%20kompendium)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What Is Kompendium](#what-is-kompendium)
|
||||
- [How to Install](#how-to-install)
|
||||
- [Library Details](#library-details)
|
||||
- [Local Development](#local-development)
|
||||
- [The Playground](#the-playground)
|
||||
|
||||
## What is Kompendium
|
||||
|
||||
### ⚠️ For info on V2 please see [here](#V2)
|
||||
|
||||
Kompendium is intended to be a minimally invasive OpenApi Specification generator for [Ktor](https://ktor.io). Minimally
|
||||
invasive meaning that users will use only Ktor native functions when implementing their API, and will supplement with
|
||||
Kompendium code in order to generate the appropriate spec.
|
||||
Kompendium is intended to be a minimally invasive OpenApi Specification generator for Ktor. Minimally invasive meaning
|
||||
that users will use only Ktor native functions when implementing their API, and will supplement with Kompendium code in
|
||||
order to generate the appropriate spec.
|
||||
|
||||
## How to install
|
||||
|
||||
Kompendium publishes all releases to Maven Central. As such, using the stable version of `Kompendium` is as simple as
|
||||
Kompendium publishes all releases to Maven Central. As such, using the release versions of `Kompendium` is as simple as
|
||||
declaring it as an implementation dependency in your `build.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
@ -23,42 +27,19 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("io.bkbn:kompendium-core:1.8.1")
|
||||
implementation("io.bkbn:kompendium-auth:1.8.1")
|
||||
implementation("io.bkbn:kompendium-swagger-ui:1.8.1")
|
||||
|
||||
// Other dependencies...
|
||||
implementation("io.bkbn:kompendium-core:latest.release")
|
||||
}
|
||||
```
|
||||
|
||||
The last two dependencies are optional.
|
||||
|
||||
If you want to get a little spicy 🤠 every merge of Kompendium is published to the GitHub package registry. Pulling from
|
||||
GitHub is slightly more involved, but such is the price you pay for bleeding edge fake data generation.
|
||||
In addition to publishing releases to Maven Central, a snapshot version gets published to GitHub Packages on every merge
|
||||
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)
|
||||
|
||||
```kotlin
|
||||
// 1 Setup a helper function to import any Github Repository Package
|
||||
// This step is optional but I have a bunch of stuff stored on github so I find it useful 😄
|
||||
fun RepositoryHandler.github(packageUrl: String) = maven {
|
||||
name = "GithubPackages"
|
||||
url = uri(packageUrl)
|
||||
credentials {
|
||||
username = java.lang.System.getenv("GITHUB_USER")
|
||||
password = java.lang.System.getenv("GITHUB_TOKEN")
|
||||
}
|
||||
}
|
||||
# Library Details
|
||||
|
||||
// 2 Add the repo in question (in this case Kompendium)
|
||||
repositories {
|
||||
github("https://maven.pkg.github.com/bkbnio/kompendium")
|
||||
}
|
||||
|
||||
// 3 Add the package like any normal dependency
|
||||
dependencies {
|
||||
implementation("io.bkbn:kompendium-core:1.8.1")
|
||||
}
|
||||
|
||||
```
|
||||
Details on how to use Kompendium can be found in the Wiki (WIP, this is new for V2 and will be fleshed out prior to release)
|
||||
|
||||
## Local Development
|
||||
|
||||
@ -66,320 +47,9 @@ Kompendium should run locally right out of the box, no configuration necessary (
|
||||
New features can be built locally and published to your local maven repository with the `./gradlew publishToMavenLocal`
|
||||
command!
|
||||
|
||||
## In depth
|
||||
## The Playground
|
||||
|
||||
### Notarized Routes
|
||||
This repo contains a `playground` module that contains a number of working examples showcasing the capabilities of
|
||||
Kompendium.
|
||||
|
||||
Kompendium introduces the concept of `notarized` HTTP methods. That is, for all your `GET`, `POST`, `PUT`, and `DELETE`
|
||||
operations, there is a corresponding `notarized` method. These operations are strongly typed, and use reification for a
|
||||
lot of the class based reflection that powers Kompendium. Generally speaking the three types that a `notarized` method
|
||||
will consume are
|
||||
|
||||
- `TParam`: Used to notarize expected request parameters
|
||||
- `TReq`: Used to build the object schema for a request body
|
||||
- `TResp`: Used to build the object schema for a response body
|
||||
|
||||
`GET` and `DELETE` take `TParam` and `TResp` while `PUT` and `POST` take all three.
|
||||
|
||||
In addition to standard HTTP Methods, Kompendium also introduced the concept of `notarizedExceptions`. Using
|
||||
the `StatusPage`
|
||||
extension, users can notarize all handled exceptions, along with their respective HTTP codes and response types.
|
||||
Exceptions that have been `notarized` require two types as supplemental information
|
||||
|
||||
- `TErr`: Used to notarize the exception being handled by this use case. Used for matching responses at the route level.
|
||||
- `TResp`: Same as above, this dictates the expected return type of the error response.
|
||||
|
||||
In keeping with minimal invasion, these extension methods all consume the same code block as a standard Ktor route
|
||||
method, meaning that swapping in a default Ktor route and a Kompendium `notarized` route is as simple as a single method
|
||||
change.
|
||||
|
||||
### Supplemental Annotations
|
||||
|
||||
In general, Kompendium tries to limit the number of annotations that developers need to use in order to get an app
|
||||
integrated.
|
||||
|
||||
Currently, the annotations used by Kompendium are as follows
|
||||
|
||||
- `KompendiumField`
|
||||
- `KompendiumParam`
|
||||
|
||||
The intended purpose of `KompendiumField` is to offer field level overrides such as naming conventions (ie snake instead
|
||||
of camel).
|
||||
|
||||
The purpose of `KompendiumParam` is to provide supplemental information needed to properly assign the type of parameter
|
||||
(cookie, header, query, path) as well as other parameter-level metadata.
|
||||
|
||||
Using backticks, users can use data classes to represent things like header names as follows
|
||||
|
||||
```kotlin
|
||||
data class HeaderNameTest(
|
||||
@KompendiumParam(type = ParamType.HEADER) val `X-UserEmail`: String
|
||||
)
|
||||
```
|
||||
|
||||
### Undeclared Field
|
||||
|
||||
There is also a final `UndeclaredField` annotation. This should be used only in an absolutely emergency. This annotation
|
||||
will allow you to inject a _single_ undeclared field that will be included as part of the schema.
|
||||
|
||||
Due to limitations in using repeated annotations, this can only be used once per class
|
||||
|
||||
This is a complete hack, and is included for odd scenarios like kotlinx serialization polymorphic adapters that expect a
|
||||
`type` field in order to perform their analysis.
|
||||
|
||||
Use this _only_ when **all** else fails
|
||||
|
||||
### Polymorphism
|
||||
|
||||
Speaking of polymorphism... out of the box, Kompendium has support for sealed classes and interfaces. At runtime, it
|
||||
will build a mapping of all available sub-classes and build a spec that takes `anyOf` the implementations. This is
|
||||
currently a weak point of the entire library, and suggestions on better implementations are welcome 🤠
|
||||
|
||||
### Serialization
|
||||
|
||||
Under the hood, Kompendium uses Jackson to serialize the final api spec. However, this implementation detail does not
|
||||
leak to the actual API, meaning that users are free to choose the serialization library of their choice.
|
||||
|
||||
Added the possibility to add your own ObjectMapper for Jackson.
|
||||
|
||||
Added a default parameter with the following configuration:
|
||||
|
||||
```kotlin
|
||||
ObjectMapper()
|
||||
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
```
|
||||
|
||||
If you want to change this default configuration and use your own ObjectMapper you only need to pass it as a second
|
||||
argument to the openApi module:
|
||||
|
||||
```kotlin
|
||||
routing {
|
||||
openApi(oas, objectMapper)
|
||||
route("/potato/spud") {
|
||||
notarizedGet(simpleGetInfo) {
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Route Handling
|
||||
|
||||
> ⚠️ Warning: Custom route handling is almost definitely an indication that either a new selector should be added to kompendium-core or that kompendium is in need of another module to handle a new ktor companion module. If you have encountered a route selector that is not already handled, please consider opening an [issue](https://github.com/bkbnio/kompendium/issues/new)
|
||||
|
||||
Kompendium does its best to handle all Ktor routes out of the gate. However, in keeping with the modular approach of
|
||||
Ktor and Kompendium, this is not always possible.
|
||||
|
||||
Should you need to, custom route handlers can be registered via the
|
||||
`Kompendium.addCustomRouteHandler` function.
|
||||
|
||||
The handler signature is as follows
|
||||
|
||||
```kotlin
|
||||
fun <T : RouteSelector> addCustomRouteHandler(
|
||||
selector: KClass<T>,
|
||||
handler: PathCalculator.(Route, String) -> String
|
||||
)
|
||||
```
|
||||
|
||||
This function takes a selector, which _must_ be a KClass of the Ktor `RouteSelector` type. The handler is a function
|
||||
that extends the Kompendium `PathCalculator`. This is necessary because it gives you access
|
||||
to `PathCalculator.calculate`, which you are going to want in order to recursively calculate the remainder of the
|
||||
route :)
|
||||
|
||||
Its parameters are the `Route` itself, along with the "tail" of the Path (the path that has been calculated thus far).
|
||||
|
||||
Working examples `init` blocks of the `PathCalculator` and `KompendiumAuth` object.
|
||||
|
||||
### Ktor Locations Plugin
|
||||
|
||||
Kompendium offers integration with the Ktor [Locations](https://ktor.io/docs/locations.html) plugin via an optional
|
||||
module `kompendium-locations`.
|
||||
|
||||
This provides a wrapper around the Locations api, provide full path analysis, along with typesafe access to request
|
||||
parameters. However, due to the way that Kompendium core parses class metadata, you will still need to annotate all
|
||||
parameters with `@KompendiumParam`. A simple example
|
||||
|
||||
```kotlin
|
||||
@Location("/test/{name}")
|
||||
data class TestLocations(
|
||||
@KompendiumParam(ParamType.PATH)
|
||||
val name: String,
|
||||
)
|
||||
```
|
||||
|
||||
When passed to a notarized route (make sure you are using the notarized methods found in
|
||||
the `io.bkbn.kompendium.locations` package ⚠️), this will provide you with a notarized route that also passes through
|
||||
the type safe instance containing all of your parameters!
|
||||
|
||||
```kotlin
|
||||
notarizedGet(testLocation) { tl ->
|
||||
call.respondText { tl.name }
|
||||
}
|
||||
```
|
||||
|
||||
A working example can be found in the `kompendium-playground` in the `LocationsSpike` file :)
|
||||
|
||||
## Examples
|
||||
|
||||
The full source code can be found in the `kompendium-playground` module. Here is a simple get endpoint example
|
||||
|
||||
```kotlin
|
||||
// Minimal API Example
|
||||
fun Application.mainModule() {
|
||||
install(StatusPages) {
|
||||
notarizedException<Exception, ExceptionResponse>(
|
||||
info = ResponseInfo(
|
||||
KompendiumHttpCodes.BAD_REQUEST,
|
||||
"Bad Things Happened"
|
||||
)
|
||||
) {
|
||||
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
|
||||
}
|
||||
}
|
||||
routing {
|
||||
openApi(oas)
|
||||
redoc(oas)
|
||||
swaggerUI()
|
||||
route("/potato/spud") {
|
||||
notarizedGet(simpleGetInfo) {
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val simpleGetInfo = GetInfo<Unit, ExampleResponse>(
|
||||
summary = "Example Parameters",
|
||||
description = "A test for setting parameter examples",
|
||||
operationId = "getExamples",
|
||||
responseInfo = ResponseInfo(
|
||||
status = 200,
|
||||
description = "nice",
|
||||
examples = mapOf("test" to ExampleResponse(c = "spud"))
|
||||
),
|
||||
canThrow = setOf(Exception::class)
|
||||
)
|
||||
```
|
||||
|
||||
### Kompendium Auth and security schemes
|
||||
|
||||
There is a separate library to handle security schemes: `kompendium-auth`. This needs to be added to your project as
|
||||
dependency.
|
||||
|
||||
At the moment, the basic and jwt authentication is only supported.
|
||||
|
||||
A minimal example would be:
|
||||
|
||||
```kotlin
|
||||
install(Authentication) {
|
||||
notarizedBasic("basic") {
|
||||
realm = "Ktor realm 1"
|
||||
// configure basic authentication provider..
|
||||
}
|
||||
notarizedJwt("jwt") {
|
||||
realm = "Ktor realm 2"
|
||||
// configure jwt authentication provider...
|
||||
}
|
||||
}
|
||||
routing {
|
||||
authenticate("basic") {
|
||||
route("/basic_auth") {
|
||||
notarizedGet(basicAuthGetInfo) {
|
||||
call.respondText { "basic auth" }
|
||||
}
|
||||
}
|
||||
}
|
||||
authenticate("jwt") {
|
||||
route("/jwt") {
|
||||
notarizedGet(jwtAuthGetInfo) {
|
||||
call.respondText { "jwt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val basicAuthGetInfo = MethodInfo<Unit, ExampleResponse>(
|
||||
summary = "Another get test",
|
||||
description = "testing more",
|
||||
responseInfo = testGetResponse,
|
||||
securitySchemes = setOf("basic")
|
||||
)
|
||||
val jwtAuthGetInfo = basicAuthGetInfo.copy(securitySchemes = setOf("jwt"))
|
||||
```
|
||||
|
||||
### Enabling Swagger ui
|
||||
|
||||
To enable Swagger UI, `kompendium-swagger-ui` needs to be added. This will also add
|
||||
the [ktor webjars feature](https://ktor.io/docs/webjars.html) to your classpath as it is required for swagger ui.
|
||||
Minimal Example:
|
||||
|
||||
```kotlin
|
||||
install(Webjars)
|
||||
routing {
|
||||
openApi(oas)
|
||||
swaggerUI()
|
||||
}
|
||||
```
|
||||
|
||||
### Enabling ReDoc
|
||||
|
||||
Unlike swagger, redoc is provided (perhaps confusingly, in the `core` module). This means out of the box
|
||||
with `kompendium-core`, you can add
|
||||
[ReDoc](https://github.com/Redocly/redoc) as follows
|
||||
|
||||
```kotlin
|
||||
routing {
|
||||
openApi(oas)
|
||||
redoc(oas)
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Type Overrides
|
||||
|
||||
Kompendium does its best to analyze types and to generate an OpenAPI format accordingly. However, there are certain
|
||||
classes that just don't play nice with the standard reflection analysis that Kompendium performs. Should you encounter a
|
||||
data type that Kompendium cannot comprehend, you will need to add it explicitly. For example, adding the Joda
|
||||
Time `DateTime` object would be as simple as the following
|
||||
|
||||
```kotlin
|
||||
Kompendium.addCustomTypeSchema(DateTime::class, FormatSchema("date-time", "string"))
|
||||
```
|
||||
|
||||
Since `Kompendium` is an object, this needs to be declared once, ahead of the actual API instantiation. This way, this
|
||||
type override can be cached ahead of reflection. Kompendium will then match all instances of this type and return the
|
||||
specified schema.
|
||||
|
||||
So how do you know a type can and cannot be inferred? The safe bet is that it can be. So go ahead and give it a shot.
|
||||
However, in the very odd scenario (almost always having to do with date/time libraries 😤) where it can't, you can rest
|
||||
safely knowing that you have the option to inject a custom override should you need to.
|
||||
|
||||
## Limitations
|
||||
|
||||
### Kompendium as a singleton
|
||||
|
||||
Currently, Kompendium exists as a Kotlin object. This comes with a couple perks, but a couple downsides. Primarily, it
|
||||
offers a seriously clean UX where the implementer doesn't need to worry about what instance to send data to. The main
|
||||
drawback, however, is that you are limited to a single API per classpath.
|
||||
|
||||
If this is a blocker, please open a GitHub issue, and we can start to think out solutions!
|
||||
|
||||
## Future Work
|
||||
|
||||
Work on V1 of Kompendium has come to a close. This, however, does not mean it has achieved complete parity with the
|
||||
OpenAPI feature spec, nor does it have all-of-the nice to have features that a truly next-gen API spec should have.
|
||||
There are several outstanding features that have been added to the
|
||||
[V2 Milestone](https://github.com/bkbnio/kompendium/milestone/2). Among others, this includes
|
||||
|
||||
- AsyncAPI Integration
|
||||
- Field Validation
|
||||
|
||||
If you have a feature that you would like to see implemented that is not on this list, or discover a 🐞, please open an
|
||||
issue [here](https://github.com/bkbnio/kompendium/issues/new)
|
||||
|
||||
### V2
|
||||
|
||||
Due to the large number of breaking changes that will be made in version 2, development is currently being done on the
|
||||
long-lived `v2` feature branch. If you are working on any feature in the `V2` milestone, please target that branch!
|
||||
If you are unsure where your changes should be, please open an issue first :)
|
||||
Feel free to check it out, or even create your own example!
|
||||
|
108
build.gradle.kts
108
build.gradle.kts
@ -1,13 +1,23 @@
|
||||
import com.adarshr.gradle.testlogger.TestLoggerExtension
|
||||
import com.adarshr.gradle.testlogger.theme.ThemeType
|
||||
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
import io.bkbn.sourdough.gradle.core.extension.SourdoughLibraryExtension
|
||||
|
||||
plugins {
|
||||
id("org.jetbrains.kotlin.jvm") version "1.5.31" apply false
|
||||
id("io.gitlab.arturbosch.detekt") version "1.18.1" apply false
|
||||
id("com.adarshr.test-logger") version "3.0.0" apply false
|
||||
id("io.github.gradle-nexus.publish-plugin") version "1.1.0" apply true
|
||||
id("io.bkbn.sourdough.root") version "0.3.0"
|
||||
id("com.github.jakemarsden.git-hooks") version "0.0.2"
|
||||
}
|
||||
|
||||
sourdough {
|
||||
toolChainJavaVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_17.majorVersion))
|
||||
jvmTarget.set(JavaVersion.VERSION_11.majorVersion)
|
||||
compilerArgs.set(listOf("-opt-in=kotlin.RequiresOptIn"))
|
||||
}
|
||||
|
||||
gitHooks {
|
||||
setHooks(
|
||||
mapOf(
|
||||
"pre-commit" to "detekt",
|
||||
"pre-push" to "test"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
allprojects {
|
||||
@ -20,76 +30,20 @@ allprojects {
|
||||
else -> "$baseVersion-SNAPSHOT"
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven") }
|
||||
}
|
||||
|
||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
||||
apply(plugin = "io.gitlab.arturbosch.detekt")
|
||||
apply(plugin = "com.adarshr.test-logger")
|
||||
apply(plugin = "idea")
|
||||
apply(plugin = "jacoco")
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>() {
|
||||
finalizedBy(tasks.withType(JacocoReport::class))
|
||||
}
|
||||
|
||||
tasks.withType<JacocoReport>() {
|
||||
reports {
|
||||
html.required.set(true)
|
||||
xml.required.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
configure<JacocoPluginExtension> {
|
||||
toolVersion = "0.8.7"
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
configure<TestLoggerExtension> {
|
||||
theme = ThemeType.MOCHA
|
||||
setLogLevel("lifecycle")
|
||||
showExceptions = true
|
||||
showStackTraces = true
|
||||
showFullStackTraces = false
|
||||
showCauses = true
|
||||
slowThreshold = 2000
|
||||
showSummary = true
|
||||
showSimpleNames = false
|
||||
showPassed = true
|
||||
showSkipped = true
|
||||
showFailed = true
|
||||
showStandardStreams = false
|
||||
showPassedStandardStreams = true
|
||||
showSkippedStandardStreams = true
|
||||
showFailedStandardStreams = true
|
||||
}
|
||||
|
||||
configure<DetektExtension> {
|
||||
toolVersion = "1.18.1"
|
||||
config = files("${rootProject.projectDir}/detekt.yml")
|
||||
buildUponDefaultConfig = true
|
||||
}
|
||||
|
||||
configure<JavaPluginExtension> {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
}
|
||||
|
||||
nexusPublishing {
|
||||
repositories {
|
||||
sonatype {
|
||||
nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/"))
|
||||
snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/"))
|
||||
}
|
||||
subprojects {
|
||||
apply(plugin = "io.bkbn.sourdough.library")
|
||||
|
||||
configure<SourdoughLibraryExtension> {
|
||||
githubOrg.set("bkbnio")
|
||||
githubRepo.set("kompendium")
|
||||
libraryName.set("Kompendium")
|
||||
libraryDescription.set("A minimally invasive OpenAPI spec generator for Ktor")
|
||||
licenseName.set("MIT License")
|
||||
licenseUrl.set("https://mit-license.org")
|
||||
developerId.set("bkbnio")
|
||||
developerName.set("Ryan Brink")
|
||||
developerEmail.set("admin@bkbn.io")
|
||||
}
|
||||
}
|
||||
|
3
codacy.yml
Normal file
3
codacy.yml
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "**/*.md"
|
@ -12,9 +12,14 @@ formatting:
|
||||
active: false
|
||||
style:
|
||||
MaxLineLength:
|
||||
excludes: ['**/test/**/*', '**/testIntegration/**/*']
|
||||
excludes: ['**/test/**/*']
|
||||
active: true
|
||||
maxLineLength: 120
|
||||
MagicNumber:
|
||||
excludes: ['**/kompendium-playground/**/*', '**/test/**/*']
|
||||
naming:
|
||||
ConstructorParameterNaming:
|
||||
active: false
|
||||
performance:
|
||||
SpreadOperator:
|
||||
active: false
|
||||
|
@ -1,7 +1,12 @@
|
||||
# Kompendium
|
||||
project.version=1.11.1
|
||||
project.version=2.0.0-alpha
|
||||
# Kotlin
|
||||
kotlin.code.style=official
|
||||
# Gradle
|
||||
org.gradle.vfs.watch=true
|
||||
org.gradle.vfs.verbose=true
|
||||
org.gradle.jvmargs=-Xmx2000m
|
||||
|
||||
# Dependencies
|
||||
ktorVersion=1.6.7
|
||||
kotestVersion=5.0.3
|
||||
|
@ -1,41 +0,0 @@
|
||||
[versions]
|
||||
kotlin = "1.4.32"
|
||||
ktor = "1.6.5"
|
||||
kotlinx-serialization = "1.2.1"
|
||||
jackson-kotlin = "2.12.0"
|
||||
slf4j = "1.7.30"
|
||||
logback = "1.2.3"
|
||||
swagger-ui = "3.47.1"
|
||||
|
||||
[libraries]
|
||||
# API
|
||||
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
|
||||
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
|
||||
ktor-jackson = { group = "io.ktor", name = "ktor-jackson", version.ref = "ktor" }
|
||||
ktor-serialization = { group = "io.ktor", name = "ktor-serialization", version.ref = "ktor" }
|
||||
ktor-html-builder = { group = "io.ktor", name = "ktor-html-builder", version.ref = "ktor" }
|
||||
ktor-auth-lib = { group = "io.ktor", name = "ktor-auth", version.ref = "ktor" }
|
||||
ktor-auth-jwt = { group = "io.ktor", name = "ktor-auth-jwt", version.ref = "ktor" }
|
||||
ktor-webjars = { group = "io.ktor", name = "ktor-webjars", version.ref = "ktor" }
|
||||
ktor-locations = { group = "io.ktor", name = "ktor-locations", version.ref = "ktor" }
|
||||
|
||||
# Serialization
|
||||
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson-kotlin" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
|
||||
|
||||
# Logging
|
||||
slf4j = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
|
||||
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
|
||||
logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = "logback" }
|
||||
|
||||
# webjars
|
||||
webjars-swagger-ui = { group = "org.webjars", name = "swagger-ui", version.ref = "swagger-ui" }
|
||||
|
||||
# Testing
|
||||
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
|
||||
|
||||
[bundles]
|
||||
ktor = ["ktor-server-core", "ktor-server-netty", "ktor-html-builder"]
|
||||
ktorAuth = ["ktor-auth-lib", "ktor-auth-jwt"]
|
||||
logging = ["slf4j", "logback-classic", "logback-core"]
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
269
gradlew
vendored
269
gradlew
vendored
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -17,67 +17,101 @@
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
@ -106,80 +140,95 @@ location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
9
kompendium-annotations/Module.md
Normal file
9
kompendium-annotations/Module.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Module kompendium-annotations
|
||||
|
||||
This module houses all annotations that Kompendium uses to provide key metadata when performing reflective analysis.
|
||||
|
||||
It is separated from core predominantly to allow for potential future integrations with [Kotlin Symbol Processing](https://github.com/google/ksp)
|
||||
|
||||
# Package io.bkbn.kompendium.annotations
|
||||
|
||||
Contains all annotations used by Kompendium
|
3
kompendium-annotations/build.gradle.kts
Normal file
3
kompendium-annotations/build.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("io.bkbn.sourdough.library")
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package io.bkbn.kompendium.annotations
|
||||
|
||||
/**
|
||||
* Annotation used to perform field level overrides.
|
||||
* @param name Indicates that a field name override is desired. Often used for camel case to snake case conversions.
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class Field(val name: String = "", val description: String = "")
|
@ -2,4 +2,4 @@ package io.bkbn.kompendium.annotations
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class KompendiumField(val name: String)
|
||||
annotation class FreeFormObject
|
@ -3,15 +3,7 @@ package io.bkbn.kompendium.annotations
|
||||
/**
|
||||
* Used to indicate that a field in a data class represents an OpenAPI parameter
|
||||
* @param type The type of parameter, must be valid [ParamType]
|
||||
* @param description Description of the parameter to include in OpenAPI Spec
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class KompendiumParam(val type: ParamType, val description: String = "")
|
||||
|
||||
enum class ParamType {
|
||||
COOKIE,
|
||||
HEADER,
|
||||
PATH,
|
||||
QUERY
|
||||
}
|
||||
annotation class Param(val type: ParamType)
|
@ -0,0 +1,11 @@
|
||||
package io.bkbn.kompendium.annotations
|
||||
|
||||
/**
|
||||
* The allowed parameter types as specified by the OpenAPI specification
|
||||
*/
|
||||
enum class ParamType {
|
||||
COOKIE,
|
||||
HEADER,
|
||||
PATH,
|
||||
QUERY
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package io.bkbn.kompendium.annotations
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* This annotation allows users to add additional fields that are not part of the core data model. This should be used
|
||||
* EXTREMELY sparingly. Most useful in supporting a variety of polymorphic serialization techniques.
|
||||
* @param field Name of the extra field to add to the model
|
||||
* @param clazz Class type of the field being added. If this is a complex type, you are most likely doing something
|
||||
* wrong.
|
||||
*/
|
||||
@Repeatable
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class UndeclaredField(val field: String, val clazz: KClass<*>)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class Format(val format: String)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MaxItems(val items: Int)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MaxLength(val length: Int)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MaxProperties(val properties: Int)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class Maximum(val max: String, val exclusive: Boolean = false)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MinItems(val items: Int)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MinLength(val length: Int)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MinProperties(val properties: Int)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class Minimum(val min: String, val exclusive: Boolean = false)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class MultipleOf(val multiple: String)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class Pattern(val pattern: String)
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.annotations.constraint
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
annotation class UniqueItems
|
9
kompendium-auth/Module.md
Normal file
9
kompendium-auth/Module.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Module kompendium-auth
|
||||
|
||||
This module is responsible for providing wrappers around ktor-auth configuration blocks, allowing users to document
|
||||
their API authentication with minimal modifications to their existing configuration.
|
||||
|
||||
# Package io.bkbn.kompendium.auth
|
||||
|
||||
Base package that is responsible for setting up required authentication route handlers along with exposing
|
||||
wrapper methods for each ktor-auth authentication mechanism.
|
@ -1,78 +1,18 @@
|
||||
plugins {
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
signing
|
||||
id("io.bkbn.sourdough.library")
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
implementation(libs.bundles.ktor)
|
||||
implementation(libs.bundles.ktorAuth)
|
||||
// IMPLEMENTATION
|
||||
|
||||
val ktorVersion: String by project
|
||||
implementation(projects.kompendiumCore)
|
||||
implementation(group = "io.ktor", name = "ktor-server-core", version = ktorVersion)
|
||||
implementation(group = "io.ktor", name = "ktor-auth", version = ktorVersion)
|
||||
implementation(group = "io.ktor", name = "ktor-auth-jwt", version = ktorVersion)
|
||||
|
||||
testImplementation(libs.ktor.jackson)
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
|
||||
testImplementation(libs.jackson.module.kotlin)
|
||||
testImplementation(libs.ktor.server.test.host)
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
repositories {
|
||||
maven {
|
||||
name = "GithubPackages"
|
||||
url = uri("https://maven.pkg.github.com/bkbnio/kompendium")
|
||||
credentials {
|
||||
username = System.getenv("GITHUB_ACTOR")
|
||||
password = System.getenv("GITHUB_TOKEN")
|
||||
}
|
||||
}
|
||||
}
|
||||
publications {
|
||||
create<MavenPublication>("kompendium") {
|
||||
from(components["kotlin"])
|
||||
artifact(tasks.sourcesJar)
|
||||
artifact(tasks.javadocJar)
|
||||
groupId = project.group.toString()
|
||||
artifactId = project.name.toLowerCase()
|
||||
version = project.version.toString()
|
||||
|
||||
pom {
|
||||
name.set("Kompendium")
|
||||
description.set("A minimally invasive OpenAPI spec generator for Ktor")
|
||||
url.set("https://github.com/bkbnio/Kompendium")
|
||||
licenses {
|
||||
license {
|
||||
name.set("MIT License")
|
||||
url.set("https://mit-license.org/")
|
||||
}
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id.set("bkbnio")
|
||||
name.set("Ryan Brink")
|
||||
email.set("admin@bkbn.io")
|
||||
}
|
||||
}
|
||||
scm {
|
||||
connection.set("scm:git:git://github.com/bkbnio/Kompendium.git")
|
||||
developerConnection.set("scm:git:ssh://github.com/bkbnio/Kompendium.git")
|
||||
url.set("https://github.com/bkbnio/Kompendium.git")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signing {
|
||||
val signingKey: String? by project
|
||||
val signingPassword: String? by project
|
||||
useInMemoryPgpKeys(signingKey, signingPassword)
|
||||
sign(publishing.publications)
|
||||
// TESTING
|
||||
|
||||
testImplementation(testFixtures(projects.kompendiumCore))
|
||||
}
|
||||
|
@ -1,53 +0,0 @@
|
||||
package io.bkbn.kompendium.auth
|
||||
|
||||
import io.ktor.auth.Authentication
|
||||
import io.ktor.auth.basic
|
||||
import io.ktor.auth.BasicAuthenticationProvider
|
||||
import io.ktor.auth.jwt.jwt
|
||||
import io.ktor.auth.jwt.JWTAuthenticationProvider
|
||||
import io.bkbn.kompendium.Kompendium
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecSchemaSecurity
|
||||
import io.ktor.auth.AuthenticationRouteSelector
|
||||
|
||||
object KompendiumAuth {
|
||||
|
||||
init {
|
||||
Kompendium.addCustomRouteHandler(AuthenticationRouteSelector::class) { route, tail ->
|
||||
calculate(route.parent, tail)
|
||||
}
|
||||
}
|
||||
|
||||
fun Authentication.Configuration.notarizedBasic(
|
||||
name: String? = null,
|
||||
configure: BasicAuthenticationProvider.Configuration.() -> Unit
|
||||
) {
|
||||
Kompendium.openApiSpec.components.securitySchemes[name ?: "default"] = OpenApiSpecSchemaSecurity(
|
||||
type = "http",
|
||||
scheme = "basic"
|
||||
)
|
||||
basic(name, configure)
|
||||
}
|
||||
|
||||
fun Authentication.Configuration.notarizedJwt(
|
||||
name: String? = null,
|
||||
header: String? = null,
|
||||
scheme: String? = null,
|
||||
configure: JWTAuthenticationProvider.Configuration.() -> Unit
|
||||
) {
|
||||
if (header == null || header == "Authorization") {
|
||||
Kompendium.openApiSpec.components.securitySchemes[name ?: "default"] = OpenApiSpecSchemaSecurity(
|
||||
type = "http",
|
||||
scheme = scheme ?: "bearer"
|
||||
)
|
||||
} else {
|
||||
Kompendium.openApiSpec.components.securitySchemes[name ?: "default"] = OpenApiSpecSchemaSecurity(
|
||||
type = "apiKey",
|
||||
name = header,
|
||||
`in` = "header"
|
||||
)
|
||||
}
|
||||
jwt(name, configure)
|
||||
}
|
||||
|
||||
// TODO support other authentication providers (e.g., oAuth)?
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package io.bkbn.kompendium.auth
|
||||
|
||||
import io.bkbn.kompendium.auth.configuration.ApiKeyConfiguration
|
||||
import io.bkbn.kompendium.auth.configuration.BasicAuthConfiguration
|
||||
import io.bkbn.kompendium.auth.configuration.JwtAuthConfiguration
|
||||
import io.bkbn.kompendium.auth.configuration.OAuthConfiguration
|
||||
import io.bkbn.kompendium.auth.configuration.SecurityConfiguration
|
||||
import io.bkbn.kompendium.core.Kompendium
|
||||
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.ktor.application.feature
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.application
|
||||
|
||||
object Notarized {
|
||||
|
||||
fun Route.notarizedAuthenticate(
|
||||
vararg configurations: SecurityConfiguration,
|
||||
optional: Boolean = false,
|
||||
build: Route.() -> Unit
|
||||
): Route {
|
||||
val configurationNames = configurations.map { it.name }.toTypedArray()
|
||||
val feature = application.feature(Kompendium)
|
||||
|
||||
configurations.forEach { config ->
|
||||
feature.config.spec.components.securitySchemes[config.name] = when (config) {
|
||||
is ApiKeyConfiguration -> ApiKeyAuth(config.location, config.keyName)
|
||||
is BasicAuthConfiguration -> BasicAuth()
|
||||
is JwtAuthConfiguration -> BearerAuth(config.bearerFormat)
|
||||
is OAuthConfiguration -> OAuth(config.description, config.flows)
|
||||
}
|
||||
}
|
||||
|
||||
return authenticate(*configurationNames, optional = optional, build = build)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package io.bkbn.kompendium.auth.configuration
|
||||
|
||||
import io.bkbn.kompendium.oas.security.ApiKeyAuth
|
||||
|
||||
interface ApiKeyConfiguration : SecurityConfiguration {
|
||||
val location: ApiKeyAuth.ApiKeyLocation
|
||||
val keyName: String
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package io.bkbn.kompendium.auth.configuration
|
||||
|
||||
interface BasicAuthConfiguration : SecurityConfiguration
|
@ -0,0 +1,6 @@
|
||||
package io.bkbn.kompendium.auth.configuration
|
||||
|
||||
interface JwtAuthConfiguration : SecurityConfiguration {
|
||||
val bearerFormat: String
|
||||
get() = "JWT"
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package io.bkbn.kompendium.auth.configuration
|
||||
|
||||
import io.bkbn.kompendium.oas.security.OAuth
|
||||
|
||||
interface OAuthConfiguration: SecurityConfiguration {
|
||||
val flows: OAuth.Flows
|
||||
val description: String?
|
||||
get() = null
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.auth.configuration
|
||||
|
||||
sealed interface SecurityConfiguration {
|
||||
val name: String
|
||||
}
|
@ -1,201 +1,69 @@
|
||||
package io.bkbn.kompendium.auth
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import io.ktor.application.Application
|
||||
import io.ktor.application.call
|
||||
import io.ktor.application.install
|
||||
import io.ktor.auth.Authentication
|
||||
import io.ktor.auth.UserIdPrincipal
|
||||
import io.ktor.auth.authenticate
|
||||
import io.ktor.features.ContentNegotiation
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.jackson.jackson
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.route
|
||||
import io.ktor.routing.routing
|
||||
import io.ktor.server.testing.handleRequest
|
||||
import io.ktor.server.testing.withTestApplication
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.assertEquals
|
||||
import org.junit.Test
|
||||
import io.bkbn.kompendium.Kompendium
|
||||
import io.bkbn.kompendium.Notarized.notarizedGet
|
||||
import io.bkbn.kompendium.auth.KompendiumAuth.notarizedBasic
|
||||
import io.bkbn.kompendium.auth.KompendiumAuth.notarizedJwt
|
||||
import io.bkbn.kompendium.auth.util.TestData
|
||||
import io.bkbn.kompendium.auth.util.TestParams
|
||||
import io.bkbn.kompendium.auth.util.TestResponse
|
||||
import io.bkbn.kompendium.models.meta.MethodInfo
|
||||
import io.bkbn.kompendium.models.meta.ResponseInfo
|
||||
import io.bkbn.kompendium.routes.openApi
|
||||
import io.bkbn.kompendium.routes.redoc
|
||||
import io.bkbn.kompendium.auth.configuration.BasicAuthConfiguration
|
||||
import io.bkbn.kompendium.auth.configuration.JwtAuthConfiguration
|
||||
import io.bkbn.kompendium.auth.configuration.OAuthConfiguration
|
||||
import io.bkbn.kompendium.auth.util.AuthConfigName
|
||||
import io.bkbn.kompendium.auth.util.configBasicAuth
|
||||
import io.bkbn.kompendium.auth.util.configJwtAuth
|
||||
import io.bkbn.kompendium.auth.util.notarizedAuthRoute
|
||||
import io.bkbn.kompendium.auth.util.setupOauth
|
||||
import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTest
|
||||
import io.bkbn.kompendium.oas.security.OAuth
|
||||
import io.kotest.core.spec.style.DescribeSpec
|
||||
|
||||
internal class KompendiumAuthTest {
|
||||
|
||||
@AfterTest
|
||||
fun `reset kompendium`() {
|
||||
Kompendium.resetSchema()
|
||||
class KompendiumAuthTest : DescribeSpec({
|
||||
describe("Basic Authentication") {
|
||||
it("Can create a notarized basic authentication record with all expected information") {
|
||||
// arrange
|
||||
val authConfig = object : BasicAuthConfiguration {
|
||||
override val name: String = AuthConfigName.Basic
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Notarized Get with basic authentication records all expected information`() {
|
||||
withTestApplication({
|
||||
configModule()
|
||||
// act
|
||||
openApiTest("notarized_basic_authenticated_get.json") {
|
||||
configBasicAuth()
|
||||
docs()
|
||||
notarizedAuthenticatedGetModule(TestData.AuthConfigName.Basic)
|
||||
}) {
|
||||
// do
|
||||
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
|
||||
|
||||
// expect
|
||||
val expected = TestData.getFileSnapshot("notarized_basic_authenticated_get.json").trim()
|
||||
assertEquals(expected, json, "The received json spec should match the expected content")
|
||||
notarizedAuthRoute(authConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
describe("JWT Authentication") {
|
||||
it("Can create a simple notarized JWT route") {
|
||||
// arrange
|
||||
val authConfig = object : JwtAuthConfiguration {
|
||||
override val name: String = AuthConfigName.JWT
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Notarized Get with jwt authentication records all expected information`() {
|
||||
withTestApplication({
|
||||
configModule()
|
||||
// act
|
||||
openApiTest("notarized_jwt_authenticated_get.json") {
|
||||
configJwtAuth()
|
||||
docs()
|
||||
notarizedAuthenticatedGetModule(TestData.AuthConfigName.JWT)
|
||||
}) {
|
||||
// do
|
||||
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
|
||||
|
||||
// expect
|
||||
val expected = TestData.getFileSnapshot("notarized_jwt_authenticated_get.json").trim()
|
||||
assertEquals(expected, json, "The received json spec should match the expected content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Notarized Get with jwt authentication and custom scheme records all expected information`() {
|
||||
withTestApplication({
|
||||
configModule()
|
||||
configJwtAuth(scheme = "oauth")
|
||||
docs()
|
||||
notarizedAuthenticatedGetModule(TestData.AuthConfigName.JWT)
|
||||
}) {
|
||||
// do
|
||||
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
|
||||
|
||||
// expect
|
||||
val expected = TestData.getFileSnapshot("notarized_jwt_custom_scheme_authenticated_get.json").trim()
|
||||
assertEquals(expected, json, "The received json spec should match the expected content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Notarized Get with jwt authentication and custom header records all expected information`() {
|
||||
withTestApplication({
|
||||
configModule()
|
||||
configJwtAuth(header = "x-api-key")
|
||||
docs()
|
||||
notarizedAuthenticatedGetModule(TestData.AuthConfigName.JWT)
|
||||
}) {
|
||||
// do
|
||||
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
|
||||
|
||||
// expect
|
||||
val expected = TestData.getFileSnapshot("notarized_jwt_custom_header_authenticated_get.json").trim()
|
||||
assertEquals(expected, json, "The received json spec should match the expected content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Notarized Get with multiple jwt schemes records all expected information`() {
|
||||
withTestApplication({
|
||||
configModule()
|
||||
install(Authentication) {
|
||||
notarizedJwt("jwt1", header = "x-api-key-1") {
|
||||
realm = "Ktor server"
|
||||
}
|
||||
notarizedJwt("jwt2", header = "x-api-key-2") {
|
||||
realm = "Ktor server"
|
||||
}
|
||||
}
|
||||
docs()
|
||||
notarizedAuthenticatedGetModule("jwt1", "jwt2")
|
||||
}) {
|
||||
// do
|
||||
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
|
||||
|
||||
// expect
|
||||
val expected = TestData.getFileSnapshot("notarized_multiple_jwt_authenticated_get.json").trim()
|
||||
assertEquals(expected, json, "The received json spec should match the expected content")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.configModule() {
|
||||
install(ContentNegotiation) {
|
||||
jackson(ContentType.Application.Json) {
|
||||
enable(SerializationFeature.INDENT_OUTPUT)
|
||||
setSerializationInclusion(JsonInclude.Include.NON_NULL)
|
||||
notarizedAuthRoute(authConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.configBasicAuth() {
|
||||
install(Authentication) {
|
||||
notarizedBasic(TestData.AuthConfigName.Basic) {
|
||||
realm = "Ktor Server"
|
||||
validate { credentials ->
|
||||
if (credentials.name == credentials.password) {
|
||||
UserIdPrincipal(credentials.name)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.configJwtAuth(
|
||||
header: String? = null,
|
||||
scheme: String? = null
|
||||
) {
|
||||
install(Authentication) {
|
||||
notarizedJwt(TestData.AuthConfigName.JWT, header, scheme) {
|
||||
realm = "Ktor server"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.notarizedAuthenticatedGetModule(vararg authenticationConfigName: String) {
|
||||
routing {
|
||||
authenticate(*authenticationConfigName) {
|
||||
route(TestData.getRoutePath) {
|
||||
notarizedGet(testGetInfo(*authenticationConfigName)) {
|
||||
call.respondText { "hey dude ‼️ congratz on the get request" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val oas = Kompendium.openApiSpec.copy()
|
||||
|
||||
private fun Application.docs() {
|
||||
routing {
|
||||
openApi(oas)
|
||||
redoc(oas)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val testGetResponse = ResponseInfo<TestResponse>(HttpStatusCode.OK, "A Successful Endeavor")
|
||||
fun testGetInfo(vararg security: String) =
|
||||
MethodInfo.GetInfo<TestParams, TestResponse>(
|
||||
summary = "Another get test",
|
||||
description = "testing more",
|
||||
responseInfo = testGetResponse,
|
||||
securitySchemes = security.toSet()
|
||||
describe("OAuth Authentication") {
|
||||
it("Can create an Oauth schema with all possible flows") {
|
||||
// arrange
|
||||
val flows = OAuth.Flows(
|
||||
implicit = OAuth.Flows.Implicit(
|
||||
"https://accounts.google.com/o/oauth2/auth",
|
||||
scopes = mapOf("test" to "is a cool scope", "this" to "is also cool")
|
||||
),
|
||||
authorizationCode = OAuth.Flows.AuthorizationCode("https://accounts.google.com/o/oauth2/auth"),
|
||||
password = OAuth.Flows.Password("https://accounts.google.com/o/oauth2/auth"),
|
||||
clientCredentials = OAuth.Flows.ClientCredential("https://accounts.google.com/token")
|
||||
)
|
||||
|
||||
val authConfig = object : OAuthConfiguration {
|
||||
override val flows: OAuth.Flows = flows
|
||||
override val name: String = AuthConfigName.OAuth
|
||||
}
|
||||
}
|
||||
|
||||
// act
|
||||
openApiTest("notarized_oauth_all_flows.json") {
|
||||
setupOauth()
|
||||
notarizedAuthRoute(authConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -1,18 +0,0 @@
|
||||
package io.bkbn.kompendium.auth.util
|
||||
|
||||
import java.io.File
|
||||
|
||||
object TestData {
|
||||
object AuthConfigName {
|
||||
const val Basic = "basic"
|
||||
const val JWT = "jwt"
|
||||
}
|
||||
|
||||
const val getRoutePath = "/test"
|
||||
|
||||
fun getFileSnapshot(fileName: String): String {
|
||||
val snapshotPath = "src/test/resources"
|
||||
val file = File("$snapshotPath/$fileName")
|
||||
return file.readText()
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package io.bkbn.kompendium.auth.util
|
||||
|
||||
import io.bkbn.kompendium.annotations.KompendiumField
|
||||
import io.bkbn.kompendium.annotations.KompendiumParam
|
||||
import io.bkbn.kompendium.annotations.ParamType
|
||||
|
||||
data class TestParams(
|
||||
@KompendiumParam(ParamType.PATH) val a: String,
|
||||
@KompendiumParam(ParamType.QUERY) val aa: Int
|
||||
)
|
||||
|
||||
data class TestRequest(
|
||||
@KompendiumField(name = "field_name")
|
||||
val b: Double,
|
||||
val aaa: List<Long>
|
||||
)
|
||||
|
||||
data class TestResponse(val c: String)
|
||||
|
@ -0,0 +1,92 @@
|
||||
package io.bkbn.kompendium.auth.util
|
||||
|
||||
import io.bkbn.kompendium.auth.Notarized.notarizedAuthenticate
|
||||
import io.bkbn.kompendium.auth.configuration.SecurityConfiguration
|
||||
import io.bkbn.kompendium.core.Notarized.notarizedGet
|
||||
import io.bkbn.kompendium.core.fixtures.TestParams
|
||||
import io.bkbn.kompendium.core.fixtures.TestResponse
|
||||
import io.bkbn.kompendium.core.fixtures.TestResponseInfo
|
||||
import io.bkbn.kompendium.core.metadata.method.GetInfo
|
||||
import io.ktor.application.Application
|
||||
import io.ktor.application.call
|
||||
import io.ktor.application.install
|
||||
import io.ktor.auth.Authentication
|
||||
import io.ktor.auth.OAuthServerSettings
|
||||
import io.ktor.auth.UserIdPrincipal
|
||||
import io.ktor.auth.basic
|
||||
import io.ktor.auth.jwt.jwt
|
||||
import io.ktor.auth.oauth
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.routing.route
|
||||
import io.ktor.routing.routing
|
||||
|
||||
fun Application.setupOauth() {
|
||||
install(Authentication) {
|
||||
oauth("oauth") {
|
||||
urlProvider = { "http://localhost:8080/callback" }
|
||||
client = HttpClient(CIO)
|
||||
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 = System.getenv("GOOGLE_CLIENT_ID"),
|
||||
clientSecret = System.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.configBasicAuth() {
|
||||
install(Authentication) {
|
||||
basic(AuthConfigName.Basic) {
|
||||
realm = "Ktor Server"
|
||||
validate { credentials ->
|
||||
if (credentials.name == credentials.password) {
|
||||
UserIdPrincipal(credentials.name)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.notarizedAuthRoute(authConfig: SecurityConfiguration) {
|
||||
routing {
|
||||
notarizedAuthenticate(authConfig) {
|
||||
route("/test") { notarizedGet(testGetInfo(authConfig.name)) {
|
||||
call.respondText { "hey dude ‼️ congratz on the get request" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.configJwtAuth() {
|
||||
install(Authentication) {
|
||||
jwt(AuthConfigName.JWT) {
|
||||
realm = "Ktor server"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testGetInfo(vararg security: String) =
|
||||
GetInfo<TestParams, TestResponse>(
|
||||
summary = "Another get test",
|
||||
description = "testing more",
|
||||
responseInfo = TestResponseInfo.testGetResponse,
|
||||
securitySchemes = security.toSet()
|
||||
)
|
||||
|
||||
object AuthConfigName {
|
||||
const val Basic = "basic"
|
||||
const val JWT = "jwt"
|
||||
const val OAuth = "oauth"
|
||||
}
|
@ -1,75 +1,94 @@
|
||||
{
|
||||
"openapi" : "3.0.3",
|
||||
"info" : { },
|
||||
"servers" : [ ],
|
||||
"paths" : {
|
||||
"/test" : {
|
||||
"get" : {
|
||||
"tags" : [ ],
|
||||
"summary" : "Another get test",
|
||||
"description" : "testing more",
|
||||
"parameters" : [ {
|
||||
"name" : "a",
|
||||
"in" : "path",
|
||||
"schema" : {
|
||||
"type" : "string"
|
||||
"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"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
}, {
|
||||
"name" : "aa",
|
||||
"in" : "query",
|
||||
"schema" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
|
||||
}
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
} ],
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "A Successful Endeavor",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/TestResponse"
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://myawesomeapi.com",
|
||||
"description": "Production instance of my API"
|
||||
},
|
||||
{
|
||||
"url": "https://staging.myawesomeapi.com",
|
||||
"description": "Where the fun stuff happens"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/test": {
|
||||
"get": {
|
||||
"tags": [],
|
||||
"summary": "Another get test",
|
||||
"description": "testing more",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "a",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"name": "aa",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A Successful Endeavor",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"c": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"c"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated" : false,
|
||||
"security" : [ {
|
||||
"basic" : [ ]
|
||||
} ]
|
||||
"deprecated": false,
|
||||
"security": [
|
||||
{
|
||||
"basic": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components" : {
|
||||
"schemas" : {
|
||||
"String" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"TestResponse" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"c" : {
|
||||
"$ref" : "#/components/schemas/String"
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"basic": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
}
|
||||
},
|
||||
"securitySchemes" : {
|
||||
"basic" : {
|
||||
"type" : "http",
|
||||
"scheme" : "basic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security" : [ ],
|
||||
"tags" : [ ]
|
||||
"security": [],
|
||||
"tags": []
|
||||
}
|
||||
|
@ -1,75 +1,95 @@
|
||||
{
|
||||
"openapi" : "3.0.3",
|
||||
"info" : { },
|
||||
"servers" : [ ],
|
||||
"paths" : {
|
||||
"/test" : {
|
||||
"get" : {
|
||||
"tags" : [ ],
|
||||
"summary" : "Another get test",
|
||||
"description" : "testing more",
|
||||
"parameters" : [ {
|
||||
"name" : "a",
|
||||
"in" : "path",
|
||||
"schema" : {
|
||||
"type" : "string"
|
||||
"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"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
}, {
|
||||
"name" : "aa",
|
||||
"in" : "query",
|
||||
"schema" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
|
||||
}
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
} ],
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "A Successful Endeavor",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/TestResponse"
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://myawesomeapi.com",
|
||||
"description": "Production instance of my API"
|
||||
},
|
||||
{
|
||||
"url": "https://staging.myawesomeapi.com",
|
||||
"description": "Where the fun stuff happens"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/test": {
|
||||
"get": {
|
||||
"tags": [],
|
||||
"summary": "Another get test",
|
||||
"description": "testing more",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "a",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"name": "aa",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A Successful Endeavor",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"c": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"c"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated" : false,
|
||||
"security" : [ {
|
||||
"jwt" : [ ]
|
||||
} ]
|
||||
"deprecated": false,
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components" : {
|
||||
"schemas" : {
|
||||
"String" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"TestResponse" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"c" : {
|
||||
"$ref" : "#/components/schemas/String"
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"jwt": {
|
||||
"bearerFormat": "JWT",
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
}
|
||||
},
|
||||
"securitySchemes" : {
|
||||
"jwt" : {
|
||||
"type" : "http",
|
||||
"scheme" : "bearer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security" : [ ],
|
||||
"tags" : [ ]
|
||||
"security": [],
|
||||
"tags": []
|
||||
}
|
||||
|
@ -1,76 +0,0 @@
|
||||
{
|
||||
"openapi" : "3.0.3",
|
||||
"info" : { },
|
||||
"servers" : [ ],
|
||||
"paths" : {
|
||||
"/test" : {
|
||||
"get" : {
|
||||
"tags" : [ ],
|
||||
"summary" : "Another get test",
|
||||
"description" : "testing more",
|
||||
"parameters" : [ {
|
||||
"name" : "a",
|
||||
"in" : "path",
|
||||
"schema" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
}, {
|
||||
"name" : "aa",
|
||||
"in" : "query",
|
||||
"schema" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
} ],
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "A Successful Endeavor",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/TestResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated" : false,
|
||||
"security" : [ {
|
||||
"jwt" : [ ]
|
||||
} ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components" : {
|
||||
"schemas" : {
|
||||
"String" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"TestResponse" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"c" : {
|
||||
"$ref" : "#/components/schemas/String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
}
|
||||
},
|
||||
"securitySchemes" : {
|
||||
"jwt" : {
|
||||
"type" : "apiKey",
|
||||
"name" : "x-api-key",
|
||||
"in" : "header"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security" : [ ],
|
||||
"tags" : [ ]
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
{
|
||||
"openapi" : "3.0.3",
|
||||
"info" : { },
|
||||
"servers" : [ ],
|
||||
"paths" : {
|
||||
"/test" : {
|
||||
"get" : {
|
||||
"tags" : [ ],
|
||||
"summary" : "Another get test",
|
||||
"description" : "testing more",
|
||||
"parameters" : [ {
|
||||
"name" : "a",
|
||||
"in" : "path",
|
||||
"schema" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
}, {
|
||||
"name" : "aa",
|
||||
"in" : "query",
|
||||
"schema" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
} ],
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "A Successful Endeavor",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/TestResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated" : false,
|
||||
"security" : [ {
|
||||
"jwt" : [ ]
|
||||
} ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components" : {
|
||||
"schemas" : {
|
||||
"String" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"TestResponse" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"c" : {
|
||||
"$ref" : "#/components/schemas/String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
}
|
||||
},
|
||||
"securitySchemes" : {
|
||||
"jwt" : {
|
||||
"type" : "http",
|
||||
"scheme" : "oauth"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security" : [ ],
|
||||
"tags" : [ ]
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
{
|
||||
"openapi" : "3.0.3",
|
||||
"info" : { },
|
||||
"servers" : [ ],
|
||||
"paths" : {
|
||||
"/test" : {
|
||||
"get" : {
|
||||
"tags" : [ ],
|
||||
"summary" : "Another get test",
|
||||
"description" : "testing more",
|
||||
"parameters" : [ {
|
||||
"name" : "a",
|
||||
"in" : "path",
|
||||
"schema" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
}, {
|
||||
"name" : "aa",
|
||||
"in" : "query",
|
||||
"schema" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
},
|
||||
"required" : true,
|
||||
"deprecated" : false
|
||||
} ],
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "A Successful Endeavor",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/TestResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated" : false,
|
||||
"security" : [ {
|
||||
"jwt1" : [ ],
|
||||
"jwt2" : [ ]
|
||||
} ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components" : {
|
||||
"schemas" : {
|
||||
"String" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"TestResponse" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"c" : {
|
||||
"$ref" : "#/components/schemas/String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int" : {
|
||||
"type" : "integer",
|
||||
"format" : "int32"
|
||||
}
|
||||
},
|
||||
"securitySchemes" : {
|
||||
"jwt1" : {
|
||||
"type" : "apiKey",
|
||||
"name" : "x-api-key-1",
|
||||
"in" : "header"
|
||||
},
|
||||
"jwt2" : {
|
||||
"type" : "apiKey",
|
||||
"name" : "x-api-key-2",
|
||||
"in" : "header"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security" : [ ],
|
||||
"tags" : [ ]
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
{
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [],
|
||||
"summary": "Another get test",
|
||||
"description": "testing more",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "a",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"name": "aa",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"required": true,
|
||||
"deprecated": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A Successful Endeavor",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"c": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"c"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": false,
|
||||
"security": [
|
||||
{
|
||||
"oauth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"oauth": {
|
||||
"flows": {
|
||||
"implicit": {
|
||||
"authorizationUrl": "https://accounts.google.com/o/oauth2/auth",
|
||||
"scopes": {
|
||||
"test": "is a cool scope",
|
||||
"this": "is also cool"
|
||||
}
|
||||
},
|
||||
"authorizationCode": {
|
||||
"authorizationUrl": "https://accounts.google.com/o/oauth2/auth",
|
||||
"scopes": {}
|
||||
},
|
||||
"password": {
|
||||
"tokenUrl": "https://accounts.google.com/o/oauth2/auth",
|
||||
"scopes": {}
|
||||
},
|
||||
"clientCredentials": {
|
||||
"tokenUrl": "https://accounts.google.com/token",
|
||||
"scopes": {}
|
||||
}
|
||||
},
|
||||
"type": "oauth2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [],
|
||||
"tags": []
|
||||
}
|
9
kompendium-core/Module.md
Normal file
9
kompendium-core/Module.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Module kompendium-core
|
||||
|
||||
This is where the magic happens. This module houses all the reflective goodness that powers Kompendium.
|
||||
|
||||
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
|
@ -1,77 +1,33 @@
|
||||
plugins {
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
signing
|
||||
id("io.bkbn.sourdough.library")
|
||||
`java-test-fixtures`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
implementation(libs.bundles.ktor)
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
|
||||
testImplementation(libs.ktor.serialization)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(libs.ktor.jackson)
|
||||
testImplementation(libs.ktor.server.test.host)
|
||||
}
|
||||
// IMPLEMENTATION
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
api(projects.kompendiumOas)
|
||||
api(projects.kompendiumAnnotations)
|
||||
|
||||
publishing {
|
||||
repositories {
|
||||
maven {
|
||||
name = "GithubPackages"
|
||||
url = uri("https://maven.pkg.github.com/bkbnio/kompendium")
|
||||
credentials {
|
||||
username = System.getenv("GITHUB_ACTOR")
|
||||
password = System.getenv("GITHUB_TOKEN")
|
||||
}
|
||||
}
|
||||
}
|
||||
publications {
|
||||
create<MavenPublication>("kompendium") {
|
||||
from(components["kotlin"])
|
||||
artifact(tasks.sourcesJar)
|
||||
artifact(tasks.javadocJar)
|
||||
groupId = project.group.toString()
|
||||
artifactId = project.name.toLowerCase()
|
||||
version = project.version.toString()
|
||||
val ktorVersion: String by project
|
||||
val kotestVersion: String by project
|
||||
implementation(group = "io.ktor", name = "ktor-server-core", version = ktorVersion)
|
||||
implementation(group = "io.ktor", name = "ktor-html-builder", version = ktorVersion)
|
||||
|
||||
pom {
|
||||
name.set("Kompendium")
|
||||
description.set("A minimally invasive OpenAPI spec generator for Ktor")
|
||||
url.set("https://github.com/bkbnio/Kompendium")
|
||||
licenses {
|
||||
license {
|
||||
name.set("MIT License")
|
||||
url.set("https://mit-license.org/")
|
||||
}
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id.set("bkbnio")
|
||||
name.set("Ryan Brink")
|
||||
email.set("admin@bkbn.io")
|
||||
}
|
||||
}
|
||||
scm {
|
||||
connection.set("scm:git:git://github.com/bkbnio/Kompendium.git")
|
||||
developerConnection.set("scm:git:ssh://github.com/bkbnio/Kompendium.git")
|
||||
url.set("https://github.com/bkbnio/Kompendium.git")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
implementation(group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version = "2.13.0")
|
||||
|
||||
signing {
|
||||
val signingKey: String? by project
|
||||
val signingPassword: String? by project
|
||||
useInMemoryPgpKeys(signingKey, signingPassword)
|
||||
sign(publishing.publications)
|
||||
// TEST FIXTURES
|
||||
|
||||
testFixturesApi(group = "io.kotest", name = "kotest-runner-junit5-jvm", version = kotestVersion)
|
||||
testFixturesApi(group = "io.kotest", name = "kotest-assertions-core-jvm", version = kotestVersion)
|
||||
testFixturesApi(group = "io.kotest", name = "kotest-property-jvm", version = kotestVersion)
|
||||
testFixturesApi(group = "io.kotest", name = "kotest-assertions-json-jvm", version = kotestVersion)
|
||||
testFixturesApi(group = "io.kotest", name = "kotest-assertions-ktor-jvm", version = "4.4.3")
|
||||
|
||||
testFixturesApi(group = "io.ktor", name = "ktor-server-core", version = ktorVersion)
|
||||
testFixturesApi(group = "io.ktor", name = "ktor-server-test-host", version = ktorVersion)
|
||||
testFixturesApi(group = "io.ktor", name = "ktor-jackson", version = ktorVersion)
|
||||
testFixturesApi(group = "io.ktor", name = "ktor-serialization", version = ktorVersion)
|
||||
|
||||
testFixturesApi(group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.3.1")
|
||||
}
|
||||
|
@ -1,49 +0,0 @@
|
||||
package io.bkbn.kompendium
|
||||
|
||||
import io.bkbn.kompendium.models.meta.ErrorMap
|
||||
import io.bkbn.kompendium.models.meta.SchemaMap
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpec
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecInfo
|
||||
import io.bkbn.kompendium.models.oas.TypedSchema
|
||||
import io.bkbn.kompendium.path.IPathCalculator
|
||||
import io.bkbn.kompendium.path.PathCalculator
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.RouteSelector
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Maintains all state for the Kompendium library
|
||||
*/
|
||||
object Kompendium {
|
||||
|
||||
var errorMap: ErrorMap = emptyMap()
|
||||
var cache: SchemaMap = emptyMap()
|
||||
|
||||
var openApiSpec = OpenApiSpec(
|
||||
info = OpenApiSpecInfo(),
|
||||
servers = mutableListOf(),
|
||||
paths = mutableMapOf()
|
||||
)
|
||||
|
||||
fun calculatePath(route: Route) = PathCalculator.calculate(route)
|
||||
|
||||
fun resetSchema() {
|
||||
openApiSpec = OpenApiSpec(
|
||||
info = OpenApiSpecInfo(),
|
||||
servers = mutableListOf(),
|
||||
paths = mutableMapOf()
|
||||
)
|
||||
cache = emptyMap()
|
||||
}
|
||||
|
||||
fun addCustomTypeSchema(clazz: KClass<*>, schema: TypedSchema) {
|
||||
cache = cache.plus(clazz.simpleName!! to schema)
|
||||
}
|
||||
|
||||
fun <T : RouteSelector> addCustomRouteHandler(
|
||||
selector: KClass<T>,
|
||||
handler: IPathCalculator.(Route, String) -> String
|
||||
) {
|
||||
PathCalculator.addCustomRouteHandler(selector, handler)
|
||||
}
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
package io.bkbn.kompendium
|
||||
|
||||
import io.ktor.routing.Route
|
||||
import kotlin.reflect.KType
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
/**
|
||||
* Functions are considered preflight when they are used to intercept a method ahead of running.
|
||||
*/
|
||||
object KompendiumPreFlight {
|
||||
|
||||
/**
|
||||
* Performs all content analysis on the types provided to a notarized route and adds it to the top level spec
|
||||
* @param TParam
|
||||
* @param TReq
|
||||
* @param TResp
|
||||
* @param block The function to execute, provided type information of the parameters above
|
||||
* @return [Route]
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> methodNotarizationPreFlight(
|
||||
block: (KType, KType, KType) -> Route
|
||||
): Route {
|
||||
val requestType = typeOf<TReq>()
|
||||
val responseType = typeOf<TResp>()
|
||||
val paramType = typeOf<TParam>()
|
||||
addToCache(paramType, requestType, responseType)
|
||||
Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache)
|
||||
return block.invoke(paramType, requestType, responseType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs all content analysis on the types provided to a notarized error and adds them to the top level spec.
|
||||
* @param TErr
|
||||
* @param TResp
|
||||
* @param block The function to execute, provided type information of the parameters above
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified TErr : Throwable, reified TResp : Any> errorNotarizationPreFlight(
|
||||
block: (KType, KType) -> Unit
|
||||
) {
|
||||
val errorType = typeOf<TErr>()
|
||||
val responseType = typeOf<TResp>()
|
||||
addToCache(typeOf<Unit>(), typeOf<Unit>(), responseType)
|
||||
Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache)
|
||||
return block.invoke(errorType, responseType)
|
||||
}
|
||||
|
||||
fun addToCache(paramType: KType, requestType: KType, responseType: KType) {
|
||||
Kompendium.cache = Kontent.generateKontent(requestType, Kompendium.cache)
|
||||
Kompendium.cache = Kontent.generateKontent(responseType, Kompendium.cache)
|
||||
Kompendium.cache = Kontent.generateParameterKontent(paramType, Kompendium.cache)
|
||||
}
|
||||
}
|
@ -1,290 +0,0 @@
|
||||
package io.bkbn.kompendium
|
||||
|
||||
import io.bkbn.kompendium.annotations.UndeclaredField
|
||||
import io.bkbn.kompendium.models.meta.SchemaMap
|
||||
import io.bkbn.kompendium.models.oas.AnyOfReferencedSchema
|
||||
import io.bkbn.kompendium.models.oas.ArraySchema
|
||||
import io.bkbn.kompendium.models.oas.DictionarySchema
|
||||
import io.bkbn.kompendium.models.oas.EnumSchema
|
||||
import io.bkbn.kompendium.models.oas.FormatSchema
|
||||
import io.bkbn.kompendium.models.oas.ObjectSchema
|
||||
import io.bkbn.kompendium.models.oas.ReferencedSchema
|
||||
import io.bkbn.kompendium.models.oas.SimpleSchema
|
||||
import io.bkbn.kompendium.util.Helpers.COMPONENT_SLUG
|
||||
import io.bkbn.kompendium.util.Helpers.genericNameAdapter
|
||||
import io.bkbn.kompendium.util.Helpers.getReferenceSlug
|
||||
import io.bkbn.kompendium.util.Helpers.getSimpleSlug
|
||||
import io.bkbn.kompendium.util.Helpers.logged
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.util.UUID
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KType
|
||||
import kotlin.reflect.full.createType
|
||||
import kotlin.reflect.full.isSubclassOf
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.reflect.jvm.javaField
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
/**
|
||||
* Responsible for generating the schema map that is used to power all object references across the API Spec.
|
||||
*/
|
||||
object Kontent {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/**
|
||||
* Analyzes a type [T] for its top-level and any nested schemas, and adds them to a [SchemaMap], if provided
|
||||
* @param T type to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
* @return an updated schema map containing all type information for [T]
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified T> generateKontent(
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap {
|
||||
val kontentType = typeOf<T>()
|
||||
return generateKTypeKontent(kontentType, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a [KType] for its top-level and any nested schemas, and adds them to a [SchemaMap], if provided
|
||||
* @param type [KType] to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
* @return an updated schema map containing all type information for [KType] type
|
||||
*/
|
||||
fun generateKontent(
|
||||
type: KType,
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap {
|
||||
var newCache = cache
|
||||
gatherSubTypes(type).forEach {
|
||||
newCache = generateKTypeKontent(it, newCache)
|
||||
}
|
||||
return newCache
|
||||
}
|
||||
|
||||
private fun gatherSubTypes(type: KType): List<KType> {
|
||||
val classifier = type.classifier as KClass<*>
|
||||
return if (classifier.isSealed) {
|
||||
classifier.sealedSubclasses.map {
|
||||
it.createType(type.arguments)
|
||||
}
|
||||
} else {
|
||||
listOf(type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a type [T], but filters out the top-level type
|
||||
* @param T type to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
* @return an updated schema map containing all type information for [T]
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified T> generateParameterKontent(
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap {
|
||||
val kontentType = typeOf<T>()
|
||||
return generateKTypeKontent(kontentType, cache)
|
||||
.filterNot { (slug, _) -> slug == (kontentType.classifier as KClass<*>).simpleName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a type but filters out the top-level type
|
||||
* @param type to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
* @return an updated schema map containing all type information for [T]
|
||||
*/
|
||||
fun generateParameterKontent(
|
||||
type: KType,
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap {
|
||||
return generateKTypeKontent(type, cache)
|
||||
.filterNot { (slug, _) -> slug == (type.classifier as KClass<*>).simpleName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fills schema map depending on [KType] classifier
|
||||
* @param type [KType] to parse
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
fun generateKTypeKontent(
|
||||
type: KType,
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap = logged(object {}.javaClass.enclosingMethod.name, mapOf("cache" to cache)) {
|
||||
logger.debug("Parsing Kontent of $type")
|
||||
when (val clazz = type.classifier as KClass<*>) {
|
||||
Unit::class -> cache
|
||||
Int::class -> cache.plus(clazz.simpleName!! to FormatSchema("int32", "integer"))
|
||||
Long::class -> cache.plus(clazz.simpleName!! to FormatSchema("int64", "integer"))
|
||||
Double::class -> cache.plus(clazz.simpleName!! to FormatSchema("double", "number"))
|
||||
Float::class -> cache.plus(clazz.simpleName!! to FormatSchema("float", "number"))
|
||||
String::class -> cache.plus(clazz.simpleName!! to SimpleSchema("string"))
|
||||
Boolean::class -> cache.plus(clazz.simpleName!! to SimpleSchema("boolean"))
|
||||
UUID::class -> cache.plus(clazz.simpleName!! to FormatSchema("uuid", "string"))
|
||||
BigDecimal::class -> cache.plus(clazz.simpleName!! to FormatSchema("double", "number"))
|
||||
BigInteger::class -> cache.plus(clazz.simpleName!! to FormatSchema("int64", "integer"))
|
||||
ByteArray::class -> cache.plus(clazz.simpleName!! to FormatSchema("byte", "string"))
|
||||
else -> when {
|
||||
clazz.isSubclassOf(Collection::class) -> handleCollectionType(type, clazz, cache)
|
||||
clazz.isSubclassOf(Enum::class) -> handleEnumType(clazz, cache)
|
||||
clazz.isSubclassOf(Map::class) -> handleMapType(type, clazz, cache)
|
||||
else -> handleComplexType(type, clazz, cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In the event of an object type, this method will parse out individual fields to recursively aggregate object map.
|
||||
* @param clazz Class of the object to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
// TODO Fix as part of this issue https://github.com/bkbnio/kompendium/issues/80
|
||||
@Suppress("LongMethod", "ComplexMethod")
|
||||
private fun handleComplexType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
// This needs to be simple because it will be stored under it's appropriate reference component implicitly
|
||||
val slug = type.getSimpleSlug()
|
||||
// Only analyze if component has not already been stored in the cache
|
||||
return when (cache.containsKey(slug)) {
|
||||
true -> {
|
||||
logger.debug("Cache already contains $slug, returning cache untouched")
|
||||
cache
|
||||
}
|
||||
false -> {
|
||||
logger.debug("$slug was not found in cache, generating now")
|
||||
var newCache = cache
|
||||
// Grabs any type parameters as a zip with the corresponding type argument
|
||||
val typeMap = clazz.typeParameters.zip(type.arguments).toMap()
|
||||
// associates each member with a Pair of prop name to property schema
|
||||
val fieldMap = clazz.memberProperties.associate { prop ->
|
||||
logger.debug("Analyzing $prop in class $clazz")
|
||||
// Grab the field of the current property
|
||||
val field = prop.javaField?.type?.kotlin ?: error("Unable to parse field type from $prop")
|
||||
logger.debug("Detected field $field")
|
||||
// Yoinks any generic types from the type map should the field be a generic
|
||||
val yoinkBaseType = if (typeMap.containsKey(prop.returnType.classifier)) {
|
||||
logger.debug("Generic type detected")
|
||||
typeMap[prop.returnType.classifier]?.type!!
|
||||
} else {
|
||||
prop.returnType
|
||||
}
|
||||
// converts the base type to a class
|
||||
val yoinkedClassifier = yoinkBaseType.classifier as KClass<*>
|
||||
// in the event of a sealed class, grab all sealed subclasses and create a type from the base args
|
||||
val yoinkedTypes = if (yoinkedClassifier.isSealed) {
|
||||
yoinkedClassifier.sealedSubclasses.map { it.createType(yoinkBaseType.arguments) }
|
||||
} else {
|
||||
listOf(yoinkBaseType)
|
||||
}
|
||||
// if the most up-to-date cache does not contain the content for this field, generate it and add to cache
|
||||
if (!newCache.containsKey(field.simpleName)) {
|
||||
logger.debug("Cache was missing ${field.simpleName}, adding now")
|
||||
yoinkedTypes.forEach {
|
||||
newCache = generateKTypeKontent(it, newCache)
|
||||
}
|
||||
}
|
||||
// TODO This in particular is worthy of a refactor... just not very well written
|
||||
// builds the appropriate property schema based on the property return type
|
||||
val propSchema = if (typeMap.containsKey(prop.returnType.classifier)) {
|
||||
if (yoinkedClassifier.isSealed) {
|
||||
val refs = yoinkedClassifier.sealedSubclasses
|
||||
.map { it.createType(yoinkBaseType.arguments) }
|
||||
.map { ReferencedSchema(it.getReferenceSlug()) }
|
||||
AnyOfReferencedSchema(refs)
|
||||
} else {
|
||||
ReferencedSchema(typeMap[prop.returnType.classifier]?.type!!.getReferenceSlug())
|
||||
}
|
||||
} else {
|
||||
if (yoinkedClassifier.isSealed) {
|
||||
val refs = yoinkedClassifier.sealedSubclasses
|
||||
.map { it.createType(yoinkBaseType.arguments) }
|
||||
.map { ReferencedSchema(it.getReferenceSlug()) }
|
||||
AnyOfReferencedSchema(refs)
|
||||
} else {
|
||||
ReferencedSchema(field.getReferenceSlug(prop))
|
||||
}
|
||||
}
|
||||
Pair(prop.name, propSchema)
|
||||
}
|
||||
logger.debug("Looking for undeclared fields")
|
||||
val undeclaredFieldMap = clazz.annotations.filterIsInstance<UndeclaredField>().associate {
|
||||
val undeclaredType = it.clazz.createType()
|
||||
newCache = generateKontent(undeclaredType, newCache)
|
||||
it.field to ReferencedSchema(undeclaredType.getReferenceSlug())
|
||||
}
|
||||
logger.debug("$slug contains $fieldMap")
|
||||
val schema = ObjectSchema(fieldMap.plus(undeclaredFieldMap))
|
||||
logger.debug("$slug schema: $schema")
|
||||
newCache.plus(slug to schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when an [Enum] is encountered
|
||||
* @param clazz Class of the object to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
private fun handleEnumType(clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
val options = clazz.java.enumConstants.map { it.toString() }.toSet()
|
||||
return cache.plus(clazz.simpleName!! to EnumSchema(options))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when a [Map] is encountered
|
||||
* @param type Map type information
|
||||
* @param clazz Map class information
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
private fun handleMapType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
logger.debug("Map detected for $type, generating schema and appending to cache")
|
||||
val (keyType, valType) = type.arguments.map { it.type }
|
||||
logger.debug("Obtained map types -> key: $keyType and value: $valType")
|
||||
if (keyType?.classifier != String::class) {
|
||||
error("Invalid Map $type: OpenAPI dictionaries must have keys of type String")
|
||||
}
|
||||
val valClass = valType?.classifier as KClass<*>
|
||||
val valClassName = valClass.simpleName
|
||||
val referenceName = genericNameAdapter(type, clazz)
|
||||
val valueReference = when (valClass.isSealed) {
|
||||
true -> {
|
||||
val subTypes = gatherSubTypes(valType)
|
||||
AnyOfReferencedSchema(subTypes.map {
|
||||
ReferencedSchema(("$COMPONENT_SLUG/${it.getSimpleSlug()}"))
|
||||
})
|
||||
}
|
||||
false -> ReferencedSchema("$COMPONENT_SLUG/$valClassName")
|
||||
}
|
||||
val schema = DictionarySchema(additionalProperties = valueReference)
|
||||
val updatedCache = generateKontent(valType, cache)
|
||||
return updatedCache.plus(referenceName to schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when a [Collection] is encountered
|
||||
* @param type Collection type information
|
||||
* @param clazz Collection class information
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
private fun handleCollectionType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
logger.debug("Collection detected for $type, generating schema and appending to cache")
|
||||
val collectionType = type.arguments.first().type!!
|
||||
val collectionClass = collectionType.classifier as KClass<*>
|
||||
logger.debug("Obtained collection class: $collectionClass")
|
||||
val referenceName = genericNameAdapter(type, clazz)
|
||||
val valueReference = when (collectionClass.isSealed) {
|
||||
true -> {
|
||||
val subTypes = gatherSubTypes(collectionType)
|
||||
AnyOfReferencedSchema(subTypes.map {
|
||||
ReferencedSchema(("$COMPONENT_SLUG/${it.getSimpleSlug()}"))
|
||||
})
|
||||
}
|
||||
false -> ReferencedSchema("$COMPONENT_SLUG/${collectionClass.simpleName}")
|
||||
}
|
||||
val schema = ArraySchema(items = valueReference)
|
||||
val updatedCache = generateKontent(collectionType, cache)
|
||||
return updatedCache.plus(referenceName to schema)
|
||||
}
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
package io.bkbn.kompendium
|
||||
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.features.StatusPages
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.method
|
||||
import io.ktor.util.pipeline.PipelineContext
|
||||
import io.ktor.util.pipeline.PipelineInterceptor
|
||||
import io.bkbn.kompendium.KompendiumPreFlight.errorNotarizationPreFlight
|
||||
import io.bkbn.kompendium.MethodParser.parseErrorInfo
|
||||
import io.bkbn.kompendium.MethodParser.parseMethodInfo
|
||||
import io.bkbn.kompendium.models.meta.MethodInfo.GetInfo
|
||||
import io.bkbn.kompendium.models.meta.MethodInfo.PostInfo
|
||||
import io.bkbn.kompendium.models.meta.MethodInfo.PutInfo
|
||||
import io.bkbn.kompendium.models.meta.MethodInfo.DeleteInfo
|
||||
import io.bkbn.kompendium.models.meta.ResponseInfo
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecPathItem
|
||||
|
||||
/**
|
||||
* Notarization methods are the primary way that a Ktor API using Kompendium differentiates
|
||||
* from a default Ktor application. On instantiation, a notarized route, provided with the proper metadata,
|
||||
* will reflectively analyze all pertinent data to build a corresponding OpenAPI entry.
|
||||
*/
|
||||
object Notarized {
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP GET request
|
||||
* @param TParam The class containing all parameter fields.
|
||||
* Each field must be annotated with @[io.bkbn.kompendium.annotations.KompendiumField]
|
||||
* @param TResp Class detailing the expected API response
|
||||
* @param info Route metadata
|
||||
*/
|
||||
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
|
||||
info: GetInfo<TParam, TResp>,
|
||||
noinline body: PipelineInterceptor<Unit, ApplicationCall>
|
||||
): Route =
|
||||
KompendiumPreFlight.methodNotarizationPreFlight<TParam, Unit, TResp>() { paramType, requestType, responseType ->
|
||||
val path = Kompendium.calculatePath(this)
|
||||
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
|
||||
Kompendium.openApiSpec.paths[path]?.get = parseMethodInfo(info, paramType, requestType, responseType)
|
||||
return method(HttpMethod.Get) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP POST request
|
||||
* @param TParam The class containing all parameter fields.
|
||||
* Each field must be annotated with @[io.bkbn.kompendium.annotations.KompendiumField]
|
||||
* @param TReq Class detailing the expected API request body
|
||||
* @param TResp Class detailing the expected API response
|
||||
* @param info Route metadata
|
||||
*/
|
||||
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
|
||||
info: PostInfo<TParam, TReq, TResp>,
|
||||
noinline body: PipelineInterceptor<Unit, ApplicationCall>
|
||||
): Route =
|
||||
KompendiumPreFlight.methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
|
||||
val path = Kompendium.calculatePath(this)
|
||||
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
|
||||
Kompendium.openApiSpec.paths[path]?.post = parseMethodInfo(info, paramType, requestType, responseType)
|
||||
return method(HttpMethod.Post) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP Delete request
|
||||
* @param TParam The class containing all parameter fields.
|
||||
* Each field must be annotated with @[io.bkbn.kompendium.annotations.KompendiumField]
|
||||
* @param TReq Class detailing the expected API request body
|
||||
* @param TResp Class detailing the expected API response
|
||||
* @param info Route metadata
|
||||
*/
|
||||
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
|
||||
info: PutInfo<TParam, TReq, TResp>,
|
||||
noinline body: PipelineInterceptor<Unit, ApplicationCall>,
|
||||
): Route =
|
||||
KompendiumPreFlight.methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
|
||||
val path = Kompendium.calculatePath(this)
|
||||
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
|
||||
Kompendium.openApiSpec.paths[path]?.put =
|
||||
parseMethodInfo(info, paramType, requestType, responseType)
|
||||
return method(HttpMethod.Put) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP POST request
|
||||
* @param TParam The class containing all parameter fields.
|
||||
* Each field must be annotated with @[io.bkbn.kompendium.annotations.KompendiumField]
|
||||
* @param TResp Class detailing the expected API response
|
||||
* @param info Route metadata
|
||||
*/
|
||||
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
|
||||
info: DeleteInfo<TParam, TResp>,
|
||||
noinline body: PipelineInterceptor<Unit, ApplicationCall>
|
||||
): Route =
|
||||
KompendiumPreFlight.methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
|
||||
val path = Kompendium.calculatePath(this)
|
||||
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
|
||||
Kompendium.openApiSpec.paths[path]?.delete = parseMethodInfo(info, paramType, requestType, responseType)
|
||||
return method(HttpMethod.Delete) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for a handled exception response
|
||||
* @param TErr The [Throwable] that is being handled
|
||||
* @param TResp Class detailing the expected API response when handled
|
||||
* @param info Response metadata
|
||||
*/
|
||||
inline fun <reified TErr : Throwable, reified TResp : Any> StatusPages.Configuration.notarizedException(
|
||||
info: ResponseInfo<TResp>,
|
||||
noinline handler: suspend PipelineContext<Unit, ApplicationCall>.(TErr) -> Unit
|
||||
) = errorNotarizationPreFlight<TErr, TResp>() { errorType, responseType ->
|
||||
info.parseErrorInfo(errorType, responseType)
|
||||
exception(handler)
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
package io.bkbn.kompendium.annotations
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Repeatable
|
||||
annotation class UndeclaredField(val field: String, val clazz: KClass<*>)
|
@ -0,0 +1,55 @@
|
||||
package io.bkbn.kompendium.core
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import io.bkbn.kompendium.core.metadata.SchemaMap
|
||||
import io.bkbn.kompendium.oas.OpenApiSpec
|
||||
import io.bkbn.kompendium.oas.schema.TypedSchema
|
||||
import io.ktor.application.Application
|
||||
import io.ktor.application.ApplicationCallPipeline
|
||||
import io.ktor.application.ApplicationFeature
|
||||
import io.ktor.application.call
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.request.path
|
||||
import io.ktor.response.respondText
|
||||
import io.ktor.util.AttributeKey
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class Kompendium(val config: Configuration) {
|
||||
|
||||
class Configuration {
|
||||
lateinit var spec: OpenApiSpec
|
||||
|
||||
var cache: SchemaMap = emptyMap()
|
||||
var specRoute = "/openapi.json"
|
||||
|
||||
// TODO Add tests for this!!
|
||||
fun addCustomTypeSchema(clazz: KClass<*>, schema: TypedSchema) {
|
||||
cache = cache.plus(clazz.simpleName!! to schema)
|
||||
}
|
||||
|
||||
// TODO Add tests for this!!
|
||||
var om: ObjectMapper = ObjectMapper()
|
||||
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
|
||||
fun specToJson(): String = om.writeValueAsString(spec)
|
||||
}
|
||||
|
||||
companion object Feature : ApplicationFeature<Application, Configuration, Kompendium> {
|
||||
override val key: AttributeKey<Kompendium> = AttributeKey("Kompendium")
|
||||
override fun install(pipeline: Application, configure: Configuration.() -> Unit): Kompendium {
|
||||
val configuration = Configuration().apply(configure)
|
||||
|
||||
pipeline.intercept(ApplicationCallPipeline.Call) {
|
||||
if (call.request.path() == configuration.specRoute) {
|
||||
call.respondText { configuration.specToJson() }
|
||||
call.response.status(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
return Kompendium(configuration)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package io.bkbn.kompendium.core
|
||||
|
||||
import io.ktor.application.feature
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.application
|
||||
import kotlin.reflect.KType
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
/**
|
||||
* Functions are considered preflight when they are used to intercept a method ahead of running.
|
||||
*/
|
||||
object KompendiumPreFlight {
|
||||
|
||||
/**
|
||||
* Performs all content analysis on the types provided to a notarized route and adds it to the top level spec
|
||||
* @param TParam
|
||||
* @param TReq
|
||||
* @param TResp
|
||||
* @param block The function to execute, provided type information of the parameters above
|
||||
* @return [Route]
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.methodNotarizationPreFlight(
|
||||
block: (KType, KType, KType) -> Route
|
||||
): Route {
|
||||
val feature = this.application.feature(Kompendium)
|
||||
val requestType = typeOf<TReq>()
|
||||
val responseType = typeOf<TResp>()
|
||||
val paramType = typeOf<TParam>()
|
||||
addToCache(paramType, requestType, responseType, feature)
|
||||
return block.invoke(paramType, requestType, responseType)
|
||||
}
|
||||
|
||||
fun addToCache(paramType: KType, requestType: KType, responseType: KType, feature: Kompendium) {
|
||||
feature.config.cache = Kontent.generateKontent(requestType, feature.config.cache)
|
||||
feature.config.cache = Kontent.generateKontent(responseType, feature.config.cache)
|
||||
feature.config.cache = Kontent.generateKontent(paramType, feature.config.cache)
|
||||
}
|
||||
}
|
@ -0,0 +1,457 @@
|
||||
package io.bkbn.kompendium.core
|
||||
|
||||
import io.bkbn.kompendium.annotations.Field
|
||||
import io.bkbn.kompendium.annotations.FreeFormObject
|
||||
import io.bkbn.kompendium.annotations.UndeclaredField
|
||||
import io.bkbn.kompendium.annotations.constraint.Format
|
||||
import io.bkbn.kompendium.annotations.constraint.MaxItems
|
||||
import io.bkbn.kompendium.annotations.constraint.MaxLength
|
||||
import io.bkbn.kompendium.annotations.constraint.MaxProperties
|
||||
import io.bkbn.kompendium.annotations.constraint.Maximum
|
||||
import io.bkbn.kompendium.annotations.constraint.MinItems
|
||||
import io.bkbn.kompendium.annotations.constraint.MinLength
|
||||
import io.bkbn.kompendium.annotations.constraint.MinProperties
|
||||
import io.bkbn.kompendium.annotations.constraint.Minimum
|
||||
import io.bkbn.kompendium.annotations.constraint.MultipleOf
|
||||
import io.bkbn.kompendium.annotations.constraint.Pattern
|
||||
import io.bkbn.kompendium.annotations.constraint.UniqueItems
|
||||
import io.bkbn.kompendium.core.metadata.SchemaMap
|
||||
import io.bkbn.kompendium.core.metadata.TypeMap
|
||||
import io.bkbn.kompendium.core.util.Helpers.genericNameAdapter
|
||||
import io.bkbn.kompendium.core.util.Helpers.getSimpleSlug
|
||||
import io.bkbn.kompendium.core.util.Helpers.logged
|
||||
import io.bkbn.kompendium.core.util.Helpers.toNumber
|
||||
import io.bkbn.kompendium.oas.schema.AnyOfSchema
|
||||
import io.bkbn.kompendium.oas.schema.ArraySchema
|
||||
import io.bkbn.kompendium.oas.schema.ComponentSchema
|
||||
import io.bkbn.kompendium.oas.schema.DictionarySchema
|
||||
import io.bkbn.kompendium.oas.schema.EnumSchema
|
||||
import io.bkbn.kompendium.oas.schema.FormattedSchema
|
||||
import io.bkbn.kompendium.oas.schema.FreeFormSchema
|
||||
import io.bkbn.kompendium.oas.schema.ObjectSchema
|
||||
import io.bkbn.kompendium.oas.schema.SimpleSchema
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KClassifier
|
||||
import kotlin.reflect.KProperty1
|
||||
import kotlin.reflect.KType
|
||||
import kotlin.reflect.full.createType
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
import kotlin.reflect.full.isSubclassOf
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
import kotlin.reflect.jvm.javaField
|
||||
import kotlin.reflect.typeOf
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Responsible for generating the schema map that is used to power all object references across the API Spec.
|
||||
*/
|
||||
object Kontent {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/**
|
||||
* Analyzes a type [T] for its top-level and any nested schemas, and adds them to a [SchemaMap], if provided
|
||||
* @param T type to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
* @return an updated schema map containing all type information for [T]
|
||||
*/
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
inline fun <reified T> generateKontent(
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap {
|
||||
val kontentType = typeOf<T>()
|
||||
return generateKTypeKontent(kontentType, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a [KType] for its top-level and any nested schemas, and adds them to a [SchemaMap], if provided
|
||||
* @param type [KType] to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
* @return an updated schema map containing all type information for [KType] type
|
||||
*/
|
||||
fun generateKontent(
|
||||
type: KType,
|
||||
cache: SchemaMap = emptyMap()
|
||||
): SchemaMap {
|
||||
var newCache = cache
|
||||
gatherSubTypes(type).forEach {
|
||||
newCache = generateKTypeKontent(it, newCache)
|
||||
}
|
||||
return newCache
|
||||
}
|
||||
|
||||
private fun gatherSubTypes(type: KType): List<KType> {
|
||||
val classifier = type.classifier as KClass<*>
|
||||
return if (classifier.isSealed) {
|
||||
classifier.sealedSubclasses.map {
|
||||
it.createType(type.arguments)
|
||||
}
|
||||
} else {
|
||||
listOf(type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fills schema map depending on [KType] classifier
|
||||
* @param type [KType] to parse
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
fun generateKTypeKontent(
|
||||
type: KType,
|
||||
cache: SchemaMap = emptyMap(),
|
||||
): SchemaMap = logged(object {}.javaClass.enclosingMethod.name, mapOf("cache" to cache)) {
|
||||
logger.debug("Parsing Kontent of $type")
|
||||
when (val clazz = type.classifier as KClass<*>) {
|
||||
Unit::class -> cache
|
||||
Int::class -> cache.plus(clazz.simpleName!! to FormattedSchema("int32", "integer"))
|
||||
Long::class -> cache.plus(clazz.simpleName!! to FormattedSchema("int64", "integer"))
|
||||
Double::class -> cache.plus(clazz.simpleName!! to FormattedSchema("double", "number"))
|
||||
Float::class -> cache.plus(clazz.simpleName!! to FormattedSchema("float", "number"))
|
||||
String::class -> cache.plus(clazz.simpleName!! to SimpleSchema("string"))
|
||||
Boolean::class -> cache.plus(clazz.simpleName!! to SimpleSchema("boolean"))
|
||||
UUID::class -> cache.plus(clazz.simpleName!! to FormattedSchema("uuid", "string"))
|
||||
BigDecimal::class -> cache.plus(clazz.simpleName!! to FormattedSchema("double", "number"))
|
||||
BigInteger::class -> cache.plus(clazz.simpleName!! to FormattedSchema("int64", "integer"))
|
||||
ByteArray::class -> cache.plus(clazz.simpleName!! to FormattedSchema("byte", "string"))
|
||||
else -> when {
|
||||
clazz.isSubclassOf(Collection::class) -> handleCollectionType(type, clazz, cache)
|
||||
clazz.isSubclassOf(Enum::class) -> handleEnumType(clazz, cache)
|
||||
clazz.isSubclassOf(Map::class) -> handleMapType(type, clazz, cache)
|
||||
else -> handleComplexType(type, clazz, cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In the event of an object type, this method will parse out individual fields to recursively aggregate object map.
|
||||
* @param clazz Class of the object to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
@Suppress("LongMethod", "ComplexMethod")
|
||||
private fun handleComplexType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
// This needs to be simple because it will be stored under its appropriate reference component implicitly
|
||||
val slug = type.getSimpleSlug()
|
||||
// Only analyze if component has not already been stored in the cache
|
||||
return when (cache.containsKey(slug)) {
|
||||
true -> {
|
||||
logger.debug("Cache already contains $slug, returning cache untouched")
|
||||
cache
|
||||
}
|
||||
false -> {
|
||||
logger.debug("$slug was not found in cache, generating now")
|
||||
var newCache = cache
|
||||
// Grabs any type parameters mapped to the corresponding type argument(s)
|
||||
val typeMap: TypeMap = clazz.typeParameters.zip(type.arguments).toMap()
|
||||
// associates each member with a Pair of prop name to property schema
|
||||
val fieldMap = clazz.memberProperties.associate { prop ->
|
||||
logger.debug("Analyzing $prop in class $clazz")
|
||||
// Grab the field of the current property
|
||||
val field = prop.javaField?.type?.kotlin ?: error("Unable to parse field type from $prop")
|
||||
// Short circuit if data is free form
|
||||
val freeForm = prop.findAnnotation<FreeFormObject>()
|
||||
var name = prop.name
|
||||
|
||||
// todo add method to clean up
|
||||
when (freeForm) {
|
||||
null -> {
|
||||
val baseType = scanForGeneric(typeMap, prop)
|
||||
val baseClazz = baseType.classifier as KClass<*>
|
||||
val allTypes = scanForSealed(baseClazz, baseType)
|
||||
newCache = updateCache(newCache, field, allTypes)
|
||||
var propSchema = constructComponentSchema(
|
||||
typeMap = typeMap,
|
||||
prop = prop,
|
||||
fieldClazz = field,
|
||||
clazz = baseClazz,
|
||||
type = baseType,
|
||||
cache = newCache
|
||||
)
|
||||
// todo move to helper
|
||||
prop.findAnnotation<Field>()?.let { fieldOverrides ->
|
||||
if (fieldOverrides.description.isNotBlank()) {
|
||||
propSchema = propSchema.setDescription(fieldOverrides.description)
|
||||
}
|
||||
if (fieldOverrides.name.isNotBlank()) {
|
||||
name = fieldOverrides.name
|
||||
}
|
||||
}
|
||||
Pair(name, propSchema)
|
||||
}
|
||||
else -> {
|
||||
val minProperties = prop.findAnnotation<MinProperties>()
|
||||
val maxProperties = prop.findAnnotation<MaxProperties>()
|
||||
val schema =
|
||||
FreeFormSchema(minProperties = minProperties?.properties, maxProperties = maxProperties?.properties)
|
||||
Pair(name, schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.debug("Looking for undeclared fields")
|
||||
val undeclaredFieldMap = clazz.annotations.filterIsInstance<UndeclaredField>().associate {
|
||||
val undeclaredType = it.clazz.createType()
|
||||
newCache = generateKontent(undeclaredType, newCache)
|
||||
it.field to newCache[undeclaredType.getSimpleSlug()]!!
|
||||
}
|
||||
logger.debug("$slug contains $fieldMap")
|
||||
var schema = ObjectSchema(fieldMap.plus(undeclaredFieldMap))
|
||||
val requiredParams = clazz.primaryConstructor?.parameters?.filterNot { it.isOptional } ?: emptyList()
|
||||
if (requiredParams.isNotEmpty()) {
|
||||
schema = schema.copy(required = requiredParams.map { it.name!! })
|
||||
}
|
||||
logger.debug("$slug schema: $schema")
|
||||
newCache.plus(slug to schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the type information provided and adds any missing data to the schema map
|
||||
*/
|
||||
private fun updateCache(cache: SchemaMap, clazz: KClass<*>, types: List<KType>): SchemaMap {
|
||||
var newCache = cache
|
||||
if (!cache.containsKey(clazz.simpleName)) {
|
||||
logger.debug("Cache was missing ${clazz.simpleName}, adding now")
|
||||
types.forEach {
|
||||
newCache = generateKTypeKontent(it, newCache)
|
||||
}
|
||||
}
|
||||
return newCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a class for sealed subclasses. If found, returns a list with all children. Otherwise, returns
|
||||
* the base type
|
||||
*/
|
||||
private fun scanForSealed(clazz: KClass<*>, type: KType): List<KType> = if (clazz.isSealed) {
|
||||
clazz.sealedSubclasses.map { it.createType(type.arguments) }
|
||||
} else {
|
||||
listOf(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Yoinks any generic types from the type map should the field be a generic
|
||||
*/
|
||||
private fun scanForGeneric(typeMap: TypeMap, prop: KProperty1<*, *>): KType =
|
||||
if (typeMap.containsKey(prop.returnType.classifier)) {
|
||||
logger.debug("Generic type detected")
|
||||
typeMap[prop.returnType.classifier]?.type!!
|
||||
} else {
|
||||
prop.returnType
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a [ComponentSchema]
|
||||
*/
|
||||
private fun constructComponentSchema(
|
||||
typeMap: TypeMap,
|
||||
clazz: KClass<*>,
|
||||
fieldClazz: KClass<*>,
|
||||
prop: KProperty1<*, *>,
|
||||
type: KType,
|
||||
cache: SchemaMap
|
||||
): ComponentSchema =
|
||||
when (typeMap.containsKey(prop.returnType.classifier)) {
|
||||
true -> handleGenericProperty(typeMap, clazz, type, prop.returnType.classifier, cache)
|
||||
false -> handleStandardProperty(clazz, fieldClazz, prop, type, cache)
|
||||
}.scanForConstraints(clazz, prop)
|
||||
|
||||
private fun ComponentSchema.scanForConstraints(clazz: KClass<*>, prop: KProperty1<*, *>): ComponentSchema =
|
||||
when (this) {
|
||||
is AnyOfSchema -> AnyOfSchema(anyOf.map { it.scanForConstraints(clazz, prop) })
|
||||
is ArraySchema -> scanForConstraints(prop)
|
||||
is DictionarySchema -> this // TODO Anything here?
|
||||
is EnumSchema -> scanForConstraints(prop)
|
||||
is FormattedSchema -> scanForConstraints(prop)
|
||||
is FreeFormSchema -> this // todo anything here?
|
||||
is ObjectSchema -> scanForConstraints(clazz, prop)
|
||||
is SimpleSchema -> scanForConstraints(prop)
|
||||
}
|
||||
|
||||
private fun ArraySchema.scanForConstraints(prop: KProperty1<*, *>): ArraySchema {
|
||||
val minItems = prop.findAnnotation<MinItems>()
|
||||
val maxItems = prop.findAnnotation<MaxItems>()
|
||||
val uniqueItems = prop.findAnnotation<UniqueItems>()
|
||||
|
||||
return this.copy(
|
||||
minItems = minItems?.items,
|
||||
maxItems = maxItems?.items,
|
||||
uniqueItems = uniqueItems?.let { true }
|
||||
)
|
||||
}
|
||||
|
||||
private fun EnumSchema.scanForConstraints(prop: KProperty1<*, *>): EnumSchema {
|
||||
if (prop.returnType.isMarkedNullable) {
|
||||
return this.copy(nullable = true)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun FormattedSchema.scanForConstraints(prop: KProperty1<*, *>): FormattedSchema {
|
||||
val minimum = prop.findAnnotation<Minimum>()
|
||||
val maximum = prop.findAnnotation<Maximum>()
|
||||
val multipleOf = prop.findAnnotation<MultipleOf>()
|
||||
|
||||
var schema = this
|
||||
|
||||
if (prop.returnType.isMarkedNullable) {
|
||||
schema = schema.copy(nullable = true)
|
||||
}
|
||||
|
||||
return schema.copy(
|
||||
minimum = minimum?.min?.toNumber(),
|
||||
maximum = maximum?.max?.toNumber(),
|
||||
exclusiveMinimum = minimum?.exclusive,
|
||||
exclusiveMaximum = maximum?.exclusive,
|
||||
multipleOf = multipleOf?.multiple?.toNumber(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SimpleSchema.scanForConstraints(prop: KProperty1<*, *>): SimpleSchema {
|
||||
val minLength = prop.findAnnotation<MinLength>()
|
||||
val maxLength = prop.findAnnotation<MaxLength>()
|
||||
val pattern = prop.findAnnotation<Pattern>()
|
||||
val format = prop.findAnnotation<Format>()
|
||||
|
||||
var schema = this
|
||||
|
||||
if (prop.returnType.isMarkedNullable) {
|
||||
schema = schema.copy(nullable = true)
|
||||
}
|
||||
|
||||
return schema.copy(
|
||||
minLength = minLength?.length,
|
||||
maxLength = maxLength?.length,
|
||||
pattern = pattern?.pattern,
|
||||
format = format?.format
|
||||
)
|
||||
}
|
||||
|
||||
private fun ObjectSchema.scanForConstraints(clazz: KClass<*>, prop: KProperty1<*, *>): ObjectSchema {
|
||||
val requiredParams = clazz.primaryConstructor?.parameters?.filterNot { it.isOptional } ?: emptyList()
|
||||
var schema = this
|
||||
|
||||
if (requiredParams.isNotEmpty()) {
|
||||
schema = schema.copy(required = requiredParams.map { it.name!! })
|
||||
}
|
||||
|
||||
if (prop.returnType.isMarkedNullable) {
|
||||
schema = schema.copy(nullable = true)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
/**
|
||||
* If a field has no type parameters, build its [ComponentSchema] without referencing the [TypeMap]
|
||||
*/
|
||||
private fun handleStandardProperty(
|
||||
clazz: KClass<*>,
|
||||
fieldClazz: KClass<*>,
|
||||
prop: KProperty1<*, *>,
|
||||
type: KType,
|
||||
cache: SchemaMap
|
||||
): ComponentSchema = if (clazz.isSealed) {
|
||||
val refs = clazz.sealedSubclasses
|
||||
.map { it.createType(type.arguments) }
|
||||
.map { cache[it.getSimpleSlug()] ?: error("$it not found in cache") }
|
||||
AnyOfSchema(refs)
|
||||
} else {
|
||||
val slug = fieldClazz.getSimpleSlug(prop)
|
||||
cache[slug] ?: error("$slug not found in cache")
|
||||
}
|
||||
|
||||
/**
|
||||
* If a field has type parameters, leverage the constructed [TypeMap] to construct the [ComponentSchema]
|
||||
*/
|
||||
private fun handleGenericProperty(
|
||||
typeMap: TypeMap,
|
||||
clazz: KClass<*>,
|
||||
type: KType,
|
||||
classifier: KClassifier?,
|
||||
cache: SchemaMap
|
||||
): ComponentSchema = if (clazz.isSealed) {
|
||||
val refs = clazz.sealedSubclasses
|
||||
.map { it.createType(type.arguments) }
|
||||
.map { it.getSimpleSlug() }
|
||||
.map { cache[it] ?: error("$it not available in cache") }
|
||||
AnyOfSchema(refs)
|
||||
} else {
|
||||
val slug = typeMap[classifier]?.type!!.getSimpleSlug()
|
||||
cache[slug] ?: error("$slug not found in cache")
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when an [Enum] is encountered
|
||||
* @param clazz Class of the object to analyze
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
private fun handleEnumType(clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
val options = clazz.java.enumConstants.map { it.toString() }.toSet()
|
||||
return cache.plus(clazz.simpleName!! to EnumSchema(options))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when a [Map] is encountered
|
||||
* @param type Map type information
|
||||
* @param clazz Map class information
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
private fun handleMapType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
logger.debug("Map detected for $type, generating schema and appending to cache")
|
||||
val (keyType, valType) = type.arguments.map { it.type }
|
||||
logger.debug("Obtained map types -> key: $keyType and value: $valType")
|
||||
if (keyType?.classifier != String::class) {
|
||||
error("Invalid Map $type: OpenAPI dictionaries must have keys of type String")
|
||||
}
|
||||
var updatedCache = generateKTypeKontent(valType!!, cache)
|
||||
val valClass = valType.classifier as KClass<*>
|
||||
val valClassName = valClass.simpleName
|
||||
val referenceName = genericNameAdapter(type, clazz)
|
||||
val valueReference = when (valClass.isSealed) {
|
||||
true -> {
|
||||
val subTypes = gatherSubTypes(valType)
|
||||
AnyOfSchema(subTypes.map {
|
||||
updatedCache = generateKTypeKontent(it, updatedCache)
|
||||
updatedCache[it.getSimpleSlug()] ?: error("${it.getSimpleSlug()} not found")
|
||||
})
|
||||
}
|
||||
false -> updatedCache[valClassName] ?: error("$valClassName not found")
|
||||
}
|
||||
val schema = DictionarySchema(additionalProperties = valueReference)
|
||||
updatedCache = generateKontent(valType, updatedCache)
|
||||
return updatedCache.plus(referenceName to schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when a [Collection] is encountered
|
||||
* @param type Collection type information
|
||||
* @param clazz Collection class information
|
||||
* @param cache Existing schema map to append to
|
||||
*/
|
||||
private fun handleCollectionType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
|
||||
logger.debug("Collection detected for $type, generating schema and appending to cache")
|
||||
val collectionType = type.arguments.first().type!!
|
||||
val collectionClass = collectionType.classifier as KClass<*>
|
||||
logger.debug("Obtained collection class: $collectionClass")
|
||||
val referenceName = genericNameAdapter(type, clazz)
|
||||
var updatedCache = generateKTypeKontent(collectionType, cache)
|
||||
val valueReference = when (collectionClass.isSealed) {
|
||||
true -> {
|
||||
val subTypes = gatherSubTypes(collectionType)
|
||||
AnyOfSchema(subTypes.map {
|
||||
updatedCache = generateKTypeKontent(it, cache)
|
||||
updatedCache[it.getSimpleSlug()] ?: error("${it.getSimpleSlug()} not found")
|
||||
})
|
||||
}
|
||||
false -> updatedCache[collectionClass.simpleName] ?: error("${collectionClass.simpleName} not found")
|
||||
}
|
||||
val schema = ArraySchema(items = valueReference)
|
||||
updatedCache = generateKontent(collectionType, cache)
|
||||
return updatedCache.plus(referenceName to schema)
|
||||
}
|
||||
}
|
@ -1,23 +1,23 @@
|
||||
package io.bkbn.kompendium
|
||||
package io.bkbn.kompendium.core
|
||||
|
||||
import io.bkbn.kompendium.annotations.KompendiumParam
|
||||
import io.bkbn.kompendium.models.meta.MethodInfo
|
||||
import io.bkbn.kompendium.models.meta.RequestInfo
|
||||
import io.bkbn.kompendium.models.meta.ResponseInfo
|
||||
import io.bkbn.kompendium.models.oas.ExampleWrapper
|
||||
import io.bkbn.kompendium.models.oas.OpenApiAnyOf
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecMediaType
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecParameter
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecPathItemOperation
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecReferencable
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecReferenceObject
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecRequest
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecResponse
|
||||
import io.bkbn.kompendium.util.Helpers
|
||||
import io.bkbn.kompendium.util.Helpers.getReferenceSlug
|
||||
import io.bkbn.kompendium.util.Helpers.getSimpleSlug
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import io.bkbn.kompendium.annotations.Param
|
||||
import io.bkbn.kompendium.core.Kontent.generateKontent
|
||||
import io.bkbn.kompendium.core.metadata.ExceptionInfo
|
||||
import io.bkbn.kompendium.core.metadata.RequestInfo
|
||||
import io.bkbn.kompendium.core.metadata.ResponseInfo
|
||||
import io.bkbn.kompendium.core.metadata.method.MethodInfo
|
||||
import io.bkbn.kompendium.core.metadata.method.PostInfo
|
||||
import io.bkbn.kompendium.core.metadata.method.PutInfo
|
||||
import io.bkbn.kompendium.core.util.Helpers
|
||||
import io.bkbn.kompendium.core.util.Helpers.capitalized
|
||||
import io.bkbn.kompendium.core.util.Helpers.getSimpleSlug
|
||||
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.bkbn.kompendium.oas.schema.AnyOfSchema
|
||||
import io.bkbn.kompendium.oas.schema.ObjectSchema
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KParameter
|
||||
import kotlin.reflect.KProperty
|
||||
@ -26,7 +26,8 @@ import kotlin.reflect.full.createType
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
import kotlin.reflect.jvm.javaField
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The MethodParser is responsible for converting route metadata and types into an OpenAPI compatible data class.
|
||||
@ -45,29 +46,19 @@ object MethodParser {
|
||||
info: MethodInfo<*, *>,
|
||||
paramType: KType,
|
||||
requestType: KType,
|
||||
responseType: KType
|
||||
) = OpenApiSpecPathItemOperation(
|
||||
responseType: KType,
|
||||
feature: Kompendium
|
||||
) = PathOperation(
|
||||
summary = info.summary,
|
||||
description = info.description,
|
||||
operationId = info.operationId,
|
||||
tags = info.tags,
|
||||
deprecated = info.deprecated,
|
||||
parameters = paramType.toParameterSpec(),
|
||||
responses = responseType.toResponseSpec(info.responseInfo)?.let { mapOf(it) }.let {
|
||||
when (it) {
|
||||
null -> {
|
||||
val throwables = parseThrowables(info.canThrow)
|
||||
when (throwables.isEmpty()) {
|
||||
true -> null
|
||||
false -> throwables
|
||||
}
|
||||
}
|
||||
else -> it.plus(parseThrowables(info.canThrow))
|
||||
}
|
||||
},
|
||||
parameters = paramType.toParameterSpec(feature),
|
||||
responses = parseResponse(responseType, info.responseInfo, feature).plus(parseExceptions(info.canThrow, feature)),
|
||||
requestBody = when (info) {
|
||||
is MethodInfo.PutInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo)
|
||||
is MethodInfo.PostInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo)
|
||||
is PutInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo, feature)
|
||||
is PostInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo, feature)
|
||||
else -> null
|
||||
},
|
||||
security = if (info.securitySchemes.isNotEmpty()) listOf(
|
||||
@ -76,57 +67,55 @@ object MethodParser {
|
||||
) else null
|
||||
)
|
||||
|
||||
/**
|
||||
* Adds the error to the [Kompendium.errorMap] for reference in notarized routes.
|
||||
* @param errorType [KType] of the throwable being handled
|
||||
* @param responseType [KType] the type of the response sent in event of error
|
||||
*/
|
||||
fun ResponseInfo<*>.parseErrorInfo(
|
||||
errorType: KType,
|
||||
responseType: KType
|
||||
) {
|
||||
Kompendium.errorMap = Kompendium.errorMap.plus(errorType to responseType.toResponseSpec(this))
|
||||
private fun parseResponse(
|
||||
responseType: KType,
|
||||
responseInfo: ResponseInfo<*>?,
|
||||
feature: Kompendium
|
||||
): Map<Int, Response<*>> = responseType.toResponseSpec(responseInfo, feature)?.let { mapOf(it) }.orEmpty()
|
||||
|
||||
private fun parseExceptions(
|
||||
exceptionInfo: Set<ExceptionInfo<*>>,
|
||||
feature: Kompendium,
|
||||
): Map<Int, Response<*>> = exceptionInfo.associate { info ->
|
||||
feature.config.cache = generateKontent(info.responseType, feature.config.cache)
|
||||
val response = Response(
|
||||
description = info.description,
|
||||
content = feature.resolveContent(info.responseType, info.mediaTypes, info.examples)
|
||||
)
|
||||
Pair(info.status.value, response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses possible errors thrown by a route
|
||||
* @param throwables Set of classes that can be thrown
|
||||
* @return Mapping of status codes to their corresponding error spec
|
||||
*/
|
||||
private fun parseThrowables(throwables: Set<KClass<*>>): Map<Int, OpenApiSpecReferencable> = throwables.mapNotNull {
|
||||
Kompendium.errorMap[it.createType()]
|
||||
}.toMap()
|
||||
|
||||
/**
|
||||
* Converts a [KType] to an [OpenApiSpecRequest]
|
||||
* Converts a [KType] to an [Request]
|
||||
* @receiver [KType] to convert
|
||||
* @param requestInfo request metadata
|
||||
* @return Will return a generated [OpenApiSpecRequest] if requestInfo is not null
|
||||
* @return Will return a generated [Request] if requestInfo is not null
|
||||
*/
|
||||
private fun KType.toRequestSpec(requestInfo: RequestInfo<*>?): OpenApiSpecRequest<*>? =
|
||||
private fun KType.toRequestSpec(requestInfo: RequestInfo<*>?, feature: Kompendium): Request<*>? =
|
||||
when (requestInfo) {
|
||||
null -> null
|
||||
else -> {
|
||||
OpenApiSpecRequest(
|
||||
Request(
|
||||
description = requestInfo.description,
|
||||
content = resolveContent(this, requestInfo.mediaTypes, requestInfo.examples) ?: mapOf()
|
||||
content = feature.resolveContent(this, requestInfo.mediaTypes, requestInfo.examples) ?: mapOf(),
|
||||
required = requestInfo.required
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [KType] to a pairing of http status code to [OpenApiSpecRequest]
|
||||
* Converts a [KType] to a pairing of http status code to [Response]
|
||||
* @receiver [KType] to convert
|
||||
* @param responseInfo response metadata
|
||||
* @return Will return a generated [Pair] if responseInfo is not null
|
||||
*/
|
||||
private fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?): Pair<Int, OpenApiSpecResponse<*>>? =
|
||||
private fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?, feature: Kompendium): Pair<Int, Response<*>>? =
|
||||
when (responseInfo) {
|
||||
null -> null
|
||||
else -> {
|
||||
val specResponse = OpenApiSpecResponse(
|
||||
val specResponse = Response(
|
||||
description = responseInfo.description,
|
||||
content = resolveContent(this, responseInfo.mediaTypes, responseInfo.examples)
|
||||
content = feature.resolveContent(this, responseInfo.mediaTypes, responseInfo.examples)
|
||||
)
|
||||
Pair(responseInfo.status.value, specResponse)
|
||||
}
|
||||
@ -139,27 +128,27 @@ object MethodParser {
|
||||
* @param examples Mapping of named examples of valid bodies.
|
||||
* @return Named mapping of media types.
|
||||
*/
|
||||
private fun <F> resolveContent(
|
||||
private fun <F> Kompendium.resolveContent(
|
||||
type: KType,
|
||||
mediaTypes: List<String>,
|
||||
examples: Map<String, F>
|
||||
): Map<String, OpenApiSpecMediaType<F>>? {
|
||||
): Map<String, MediaType<F>>? {
|
||||
val classifier = type.classifier as KClass<*>
|
||||
return if (type != Helpers.UNIT_TYPE && mediaTypes.isNotEmpty()) {
|
||||
mediaTypes.associateWith {
|
||||
val schema = if (classifier.isSealed) {
|
||||
val refs = classifier.sealedSubclasses
|
||||
.map { it.createType(type.arguments) }
|
||||
.map { it.getReferenceSlug() }
|
||||
.map { OpenApiSpecReferenceObject(it) }
|
||||
OpenApiAnyOf(refs)
|
||||
.map { it.getSimpleSlug() }
|
||||
.map { config.cache[it] ?: error("$it not available") }
|
||||
AnyOfSchema(refs)
|
||||
} else {
|
||||
val ref = type.getReferenceSlug()
|
||||
OpenApiSpecReferenceObject(ref)
|
||||
val ref = type.getSimpleSlug()
|
||||
config.cache[ref] ?: error("$ref not available")
|
||||
}
|
||||
OpenApiSpecMediaType(
|
||||
MediaType(
|
||||
schema = schema,
|
||||
examples = examples.mapValues { (_, v) -> ExampleWrapper(v) }.ifEmpty { null }
|
||||
examples = examples.mapValues { (_, v) -> MediaType.Example(v) }.ifEmpty { null }
|
||||
)
|
||||
}
|
||||
} else null
|
||||
@ -167,29 +156,28 @@ object MethodParser {
|
||||
|
||||
/**
|
||||
* Parses a type for all parameter information. All fields in the receiver
|
||||
* must be annotated with [io.bkbn.kompendium.annotations.KompendiumParam].
|
||||
* must be annotated with [io.bkbn.kompendium.annotations.Param].
|
||||
* @receiver type
|
||||
* @return list of valid parameter specs as detailed by the [KType] members
|
||||
* @throws [IllegalStateException] if the class could not be parsed properly
|
||||
*/
|
||||
private fun KType.toParameterSpec(): List<OpenApiSpecParameter> {
|
||||
private fun KType.toParameterSpec(feature: Kompendium): List<Parameter> {
|
||||
val clazz = classifier as KClass<*>
|
||||
return clazz.memberProperties.filter { prop ->
|
||||
prop.findAnnotation<KompendiumParam>() != null
|
||||
prop.findAnnotation<Param>() != null
|
||||
}.map { prop ->
|
||||
val field = prop.javaField?.type?.kotlin
|
||||
?: error("Unable to parse field type from $prop")
|
||||
val anny = prop.findAnnotation<KompendiumParam>()
|
||||
val wrapperSchema = feature.config.cache[this.getSimpleSlug()]!! as ObjectSchema
|
||||
val anny = prop.findAnnotation<Param>()
|
||||
?: error("Field ${prop.name} is not annotated with KompendiumParam")
|
||||
val schema = Kompendium.cache[field.getSimpleSlug(prop)]
|
||||
val schema = wrapperSchema.properties[prop.name]
|
||||
?: error("Could not find component type for $prop")
|
||||
val defaultValue = getDefaultParameterValue(clazz, prop)
|
||||
OpenApiSpecParameter(
|
||||
Parameter(
|
||||
name = prop.name,
|
||||
`in` = anny.type.name.lowercase(Locale.getDefault()),
|
||||
schema = schema.addDefault(defaultValue),
|
||||
description = anny.description.ifBlank { null },
|
||||
required = !prop.returnType.isMarkedNullable
|
||||
description = schema.description,
|
||||
required = !prop.returnType.isMarkedNullable && defaultValue == null
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -215,7 +203,7 @@ object MethodParser {
|
||||
.associateWith { defaultValueInjector(it) }
|
||||
val instance = constructor.callBy(values)
|
||||
val methods = clazz.java.methods
|
||||
val getterName = "get${prop.name.capitalize()}"
|
||||
val getterName = "get${prop.name.capitalized()}"
|
||||
val getterFunction = methods.find { it.name == getterName }
|
||||
?: error("Could not associate ${prop.name} with a getter")
|
||||
return getterFunction.invoke(instance)
|
@ -0,0 +1,114 @@
|
||||
package io.bkbn.kompendium.core
|
||||
|
||||
import io.bkbn.kompendium.annotations.Param
|
||||
import io.bkbn.kompendium.core.KompendiumPreFlight.methodNotarizationPreFlight
|
||||
import io.bkbn.kompendium.core.MethodParser.parseMethodInfo
|
||||
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.Path
|
||||
import io.bkbn.kompendium.oas.path.PathOperation
|
||||
import io.ktor.application.ApplicationCall
|
||||
import io.ktor.application.feature
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.application
|
||||
import io.ktor.routing.method
|
||||
import io.ktor.util.pipeline.PipelineInterceptor
|
||||
|
||||
/**
|
||||
* Notarization methods are the primary way that a Ktor API using Kompendium differentiates
|
||||
* from a default Ktor application. On instantiation, a notarized route, provided with the proper metadata,
|
||||
* will reflectively analyze all pertinent data to build a corresponding OpenAPI entry.
|
||||
*/
|
||||
object Notarized {
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP GET request
|
||||
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
|
||||
* @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: PipelineInterceptor<Unit, ApplicationCall>
|
||||
): Route = methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
|
||||
val feature = this.application.feature(Kompendium)
|
||||
val path = calculateRoutePath()
|
||||
feature.config.spec.paths.getOrPut(path) { Path() }
|
||||
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
|
||||
feature.config.spec.paths[path]?.get = postProcess(baseInfo)
|
||||
return method(HttpMethod.Get) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP POST request
|
||||
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
|
||||
* @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: PipelineInterceptor<Unit, ApplicationCall>
|
||||
): Route = methodNotarizationPreFlight<TParam, TReq, TResp> { paramType, requestType, responseType ->
|
||||
val feature = this.application.feature(Kompendium)
|
||||
val path = calculateRoutePath()
|
||||
feature.config.spec.paths.getOrPut(path) { Path() }
|
||||
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
|
||||
feature.config.spec.paths[path]?.post = postProcess(baseInfo)
|
||||
return method(HttpMethod.Post) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP Delete request
|
||||
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
|
||||
* @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: PipelineInterceptor<Unit, ApplicationCall>,
|
||||
): Route = methodNotarizationPreFlight<TParam, TReq, TResp> { paramType, requestType, responseType ->
|
||||
val feature = this.application.feature(Kompendium)
|
||||
val path = calculateRoutePath()
|
||||
feature.config.spec.paths.getOrPut(path) { Path() }
|
||||
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
|
||||
feature.config.spec.paths[path]?.put = postProcess(baseInfo)
|
||||
return method(HttpMethod.Put) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notarization for an HTTP POST request
|
||||
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
|
||||
* @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: PipelineInterceptor<Unit, ApplicationCall>
|
||||
): Route = methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
|
||||
val feature = this.application.feature(Kompendium)
|
||||
val path = calculateRoutePath()
|
||||
feature.config.spec.paths.getOrPut(path) { Path() }
|
||||
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
|
||||
feature.config.spec.paths[path]?.delete = postProcess(baseInfo)
|
||||
return method(HttpMethod.Delete) { handle(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the built-in Ktor route path [Route.toString] but cuts out any meta route such as authentication... anything
|
||||
* that matches the RegEx pattern `/\\(.+\\)`
|
||||
*/
|
||||
fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "")
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package io.bkbn.kompendium.core.metadata
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import kotlin.reflect.KType
|
||||
|
||||
data class ExceptionInfo<TResp : Any>(
|
||||
val responseType: KType,
|
||||
val status: HttpStatusCode,
|
||||
val description: String,
|
||||
val mediaTypes: List<String> = listOf("application/json"),
|
||||
val examples: Map<String, TResp> = emptyMap()
|
||||
)
|
@ -1,4 +1,4 @@
|
||||
package io.bkbn.kompendium.models.meta
|
||||
package io.bkbn.kompendium.core.metadata
|
||||
|
||||
data class RequestInfo<TReq>(
|
||||
val description: String,
|
@ -1,4 +1,4 @@
|
||||
package io.bkbn.kompendium.models.meta
|
||||
package io.bkbn.kompendium.core.metadata
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
|
@ -0,0 +1,5 @@
|
||||
package io.bkbn.kompendium.core.metadata
|
||||
|
||||
import io.bkbn.kompendium.oas.schema.ComponentSchema
|
||||
|
||||
typealias SchemaMap = Map<String, ComponentSchema>
|
@ -0,0 +1,6 @@
|
||||
package io.bkbn.kompendium.core.metadata
|
||||
|
||||
import kotlin.reflect.KTypeParameter
|
||||
import kotlin.reflect.KTypeProjection
|
||||
|
||||
typealias TypeMap = Map<KTypeParameter, KTypeProjection>
|
@ -0,0 +1,16 @@
|
||||
package io.bkbn.kompendium.core.metadata.method
|
||||
|
||||
import io.bkbn.kompendium.core.metadata.ExceptionInfo
|
||||
import io.bkbn.kompendium.core.metadata.ResponseInfo
|
||||
|
||||
data class DeleteInfo<TParam, TResp>(
|
||||
override val responseInfo: ResponseInfo<TResp>,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>
|
@ -0,0 +1,16 @@
|
||||
package io.bkbn.kompendium.core.metadata.method
|
||||
|
||||
import io.bkbn.kompendium.core.metadata.ExceptionInfo
|
||||
import io.bkbn.kompendium.core.metadata.ResponseInfo
|
||||
|
||||
data class GetInfo<TParam, TResp>(
|
||||
override val responseInfo: ResponseInfo<TResp>,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>
|
@ -0,0 +1,24 @@
|
||||
package io.bkbn.kompendium.core.metadata.method
|
||||
|
||||
import io.bkbn.kompendium.core.metadata.ExceptionInfo
|
||||
import io.bkbn.kompendium.core.metadata.ResponseInfo
|
||||
|
||||
sealed interface MethodInfo<TParam, TResp> {
|
||||
val summary: String
|
||||
val description: String?
|
||||
get() = null
|
||||
val tags: Set<String>
|
||||
get() = emptySet()
|
||||
val deprecated: Boolean
|
||||
get() = false
|
||||
val securitySchemes: Set<String>
|
||||
get() = emptySet()
|
||||
val canThrow: Set<ExceptionInfo<*>>
|
||||
get() = emptySet()
|
||||
val responseInfo: ResponseInfo<TResp>
|
||||
// TODO Is this even used anywhere?
|
||||
val parameterExamples: Map<String, TParam>
|
||||
get() = emptyMap()
|
||||
val operationId: String?
|
||||
get() = null
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package io.bkbn.kompendium.core.metadata.method
|
||||
|
||||
import io.bkbn.kompendium.core.metadata.ExceptionInfo
|
||||
import io.bkbn.kompendium.core.metadata.RequestInfo
|
||||
import io.bkbn.kompendium.core.metadata.ResponseInfo
|
||||
|
||||
data class PostInfo<TParam, TReq, TResp>(
|
||||
val requestInfo: RequestInfo<TReq>?,
|
||||
override val responseInfo: ResponseInfo<TResp>,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>
|
@ -0,0 +1,18 @@
|
||||
package io.bkbn.kompendium.core.metadata.method
|
||||
|
||||
import io.bkbn.kompendium.core.metadata.ExceptionInfo
|
||||
import io.bkbn.kompendium.core.metadata.RequestInfo
|
||||
import io.bkbn.kompendium.core.metadata.ResponseInfo
|
||||
|
||||
data class PutInfo<TParam, TReq, TResp>(
|
||||
val requestInfo: RequestInfo<TReq>,
|
||||
override val responseInfo: ResponseInfo<TResp>,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>
|
@ -1,4 +1,4 @@
|
||||
package io.bkbn.kompendium.routes
|
||||
package io.bkbn.kompendium.core.routes
|
||||
|
||||
import io.ktor.application.call
|
||||
import io.ktor.html.respondHtml
|
||||
@ -13,20 +13,19 @@ import kotlinx.html.script
|
||||
import kotlinx.html.style
|
||||
import kotlinx.html.title
|
||||
import kotlinx.html.unsafe
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpec
|
||||
|
||||
/**
|
||||
* Provides an out-of-the-box route to view docs using ReDoc
|
||||
* @param oas spec to reference
|
||||
* @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(oas: OpenApiSpec, specUrl: String = "/openapi.json") {
|
||||
fun Routing.redoc(pageTitle: String = "Docs", specUrl: String = "/openapi.json") {
|
||||
route("/docs") {
|
||||
get {
|
||||
call.respondHtml {
|
||||
head {
|
||||
title {
|
||||
+"${oas.info.title}"
|
||||
+"$pageTitle"
|
||||
}
|
||||
meta {
|
||||
charset = "utf-8"
|
@ -1,6 +1,5 @@
|
||||
package io.bkbn.kompendium.util
|
||||
package io.bkbn.kompendium.core.util
|
||||
|
||||
import io.bkbn.kompendium.util.Helpers.getReferenceSlug
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KProperty
|
||||
@ -8,17 +7,18 @@ import kotlin.reflect.KType
|
||||
import kotlin.reflect.full.createType
|
||||
import kotlin.reflect.jvm.javaField
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.Locale
|
||||
|
||||
object Helpers {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
const val COMPONENT_SLUG = "#/components/schemas"
|
||||
private const val COMPONENT_SLUG = "#/components/schemas"
|
||||
|
||||
val UNIT_TYPE by lazy { Unit::class.createType() }
|
||||
|
||||
/**
|
||||
* Higher order function that takes a map of names to objects and will log their state ahead of function invocation
|
||||
* Higher order function that takes a map of names to object and will log their state ahead of function invocation
|
||||
* along with the result of the function invocation
|
||||
*/
|
||||
fun <T> logged(functionName: String, entities: Map<String, Any>, block: () -> T): T {
|
||||
@ -70,4 +70,16 @@ object Helpers {
|
||||
.map { it.simpleName }
|
||||
return classNames.joinToString(separator = "-", prefix = "${clazz.simpleName}-")
|
||||
}
|
||||
|
||||
fun String.capitalized() = replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
|
||||
}
|
||||
|
||||
fun String.toNumber(): Number {
|
||||
return try {
|
||||
this.toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
this.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package io.bkbn.kompendium.models.meta
|
||||
|
||||
import kotlin.reflect.KType
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecResponse
|
||||
|
||||
typealias ErrorMap = Map<KType, Pair<Int, OpenApiSpecResponse<*>>?>
|
@ -1,104 +0,0 @@
|
||||
package io.bkbn.kompendium.models.meta
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
sealed class MethodInfo<TParam, TResp>(
|
||||
open val summary: String,
|
||||
open val description: String? = null,
|
||||
open val tags: Set<String> = emptySet(),
|
||||
open val deprecated: Boolean = false,
|
||||
open val securitySchemes: Set<String> = emptySet(),
|
||||
open val canThrow: Set<KClass<*>> = emptySet(),
|
||||
open val responseInfo: ResponseInfo<TResp>? = null,
|
||||
open val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
open val operationId: String? = null
|
||||
) {
|
||||
|
||||
data class GetInfo<TParam, TResp>(
|
||||
override val responseInfo: ResponseInfo<TResp>? = null,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<KClass<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>(
|
||||
summary = summary,
|
||||
description = description,
|
||||
tags = tags,
|
||||
deprecated = deprecated,
|
||||
securitySchemes = securitySchemes,
|
||||
canThrow = canThrow,
|
||||
responseInfo = responseInfo,
|
||||
parameterExamples = parameterExamples,
|
||||
operationId = operationId
|
||||
)
|
||||
|
||||
data class PostInfo<TParam, TReq, TResp>(
|
||||
val requestInfo: RequestInfo<TReq>? = null,
|
||||
override val responseInfo: ResponseInfo<TResp>? = null,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<KClass<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>(
|
||||
summary = summary,
|
||||
description = description,
|
||||
tags = tags,
|
||||
deprecated = deprecated,
|
||||
securitySchemes = securitySchemes,
|
||||
canThrow = canThrow,
|
||||
responseInfo = responseInfo,
|
||||
parameterExamples = parameterExamples,
|
||||
operationId = operationId
|
||||
)
|
||||
|
||||
data class PutInfo<TParam, TReq, TResp>(
|
||||
val requestInfo: RequestInfo<TReq>? = null,
|
||||
override val responseInfo: ResponseInfo<TResp>? = null,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<KClass<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>(
|
||||
summary = summary,
|
||||
description = description,
|
||||
tags = tags,
|
||||
deprecated = deprecated,
|
||||
securitySchemes = securitySchemes,
|
||||
canThrow = canThrow,
|
||||
parameterExamples = parameterExamples,
|
||||
operationId = operationId
|
||||
)
|
||||
|
||||
data class DeleteInfo<TParam, TResp>(
|
||||
override val responseInfo: ResponseInfo<TResp>? = null,
|
||||
override val summary: String,
|
||||
override val description: String? = null,
|
||||
override val tags: Set<String> = emptySet(),
|
||||
override val deprecated: Boolean = false,
|
||||
override val securitySchemes: Set<String> = emptySet(),
|
||||
override val canThrow: Set<KClass<*>> = emptySet(),
|
||||
override val parameterExamples: Map<String, TParam> = emptyMap(),
|
||||
override val operationId: String? = null
|
||||
) : MethodInfo<TParam, TResp>(
|
||||
summary = summary,
|
||||
description = description,
|
||||
tags = tags,
|
||||
deprecated = deprecated,
|
||||
securitySchemes = securitySchemes,
|
||||
canThrow = canThrow,
|
||||
parameterExamples = parameterExamples,
|
||||
operationId = operationId
|
||||
)
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package io.bkbn.kompendium.models.meta
|
||||
|
||||
import io.bkbn.kompendium.models.oas.OpenApiSpecComponentSchema
|
||||
|
||||
typealias SchemaMap = Map<String, OpenApiSpecComponentSchema>
|
@ -1,14 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpec(
|
||||
val openapi: String = "3.0.3",
|
||||
val info: OpenApiSpecInfo,
|
||||
// TODO Needs to default to server object with url of `/`
|
||||
val servers: MutableList<OpenApiSpecServer> = mutableListOf(),
|
||||
val paths: MutableMap<String, OpenApiSpecPathItem> = mutableMapOf(),
|
||||
val components: OpenApiSpecComponents = OpenApiSpecComponents(),
|
||||
// todo needs to reference objects in the components -> security scheme 🤔
|
||||
val security: MutableList<Map<String, List<String>>> = mutableListOf(),
|
||||
val tags: MutableList<OpenApiSpecTag> = mutableListOf(),
|
||||
val externalDocs: OpenApiSpecExternalDocumentation? = null
|
||||
)
|
@ -1,42 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
sealed class OpenApiSpecComponentSchema(open val default: Any? = null) {
|
||||
fun addDefault(default: Any?): OpenApiSpecComponentSchema = when (this) {
|
||||
is AnyOfReferencedSchema -> error("Cannot add default to anyOf reference")
|
||||
is ReferencedSchema -> this.copy(default = default)
|
||||
is ObjectSchema -> this.copy(default = default)
|
||||
is DictionarySchema -> this.copy(default = default)
|
||||
is EnumSchema -> this.copy(default = default)
|
||||
is SimpleSchema -> this.copy(default = default)
|
||||
is FormatSchema -> this.copy(default = default)
|
||||
is ArraySchema -> this.copy(default = default)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TypedSchema(open val type: String, override val default: Any? = null) : OpenApiSpecComponentSchema(default)
|
||||
|
||||
data class ReferencedSchema(val `$ref`: String, override val default: Any? = null) : OpenApiSpecComponentSchema(default)
|
||||
data class AnyOfReferencedSchema(val anyOf: List<ReferencedSchema>) : OpenApiSpecComponentSchema()
|
||||
|
||||
data class ObjectSchema(
|
||||
val properties: Map<String, OpenApiSpecComponentSchema>,
|
||||
override val default: Any? = null
|
||||
) : TypedSchema("object", default)
|
||||
|
||||
data class DictionarySchema(
|
||||
val additionalProperties: OpenApiSpecComponentSchema,
|
||||
override val default: Any? = null
|
||||
) : TypedSchema("object", default)
|
||||
|
||||
data class EnumSchema(
|
||||
val `enum`: Set<String>, override val default: Any? = null
|
||||
) : TypedSchema("string", default)
|
||||
|
||||
data class SimpleSchema(override val type: String, override val default: Any? = null) : TypedSchema(type, default)
|
||||
|
||||
data class FormatSchema(val format: String, override val type: String, override val default: Any? = null) :
|
||||
TypedSchema(type, default)
|
||||
|
||||
data class ArraySchema(val items: OpenApiSpecComponentSchema, override val default: Any? = null) :
|
||||
TypedSchema("array", default)
|
||||
|
@ -1,7 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
// TODO I *think* the only thing I need here is the security https://swagger.io/specification/#components-object
|
||||
data class OpenApiSpecComponents(
|
||||
val schemas: MutableMap<String, OpenApiSpecComponentSchema> = mutableMapOf(),
|
||||
val securitySchemes: MutableMap<String, OpenApiSpecSchemaSecurity> = mutableMapOf()
|
||||
)
|
@ -1,8 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
import java.net.URI
|
||||
|
||||
data class OpenApiSpecExternalDocumentation(
|
||||
val url: URI,
|
||||
val description: String?
|
||||
)
|
@ -1,12 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
import java.net.URI
|
||||
|
||||
data class OpenApiSpecInfo(
|
||||
var title: String? = null,
|
||||
var version: String? = null,
|
||||
var description: String? = null,
|
||||
var termsOfService: URI? = null,
|
||||
var contact: OpenApiSpecInfoContact? = null,
|
||||
var license: OpenApiSpecInfoLicense? = null
|
||||
)
|
@ -1,8 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
import java.net.URI
|
||||
|
||||
data class OpenApiSpecInfoLicense(
|
||||
var name: String,
|
||||
var url: URI? = null
|
||||
)
|
@ -1,10 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecLink(
|
||||
val operationRef: String?, // todo mutually exclusive with operationId
|
||||
val operationId: String?,
|
||||
val parameters: Map<String, String>, // todo sheesh https://swagger.io/specification/#link-object
|
||||
val requestBody: String, // todo same
|
||||
val description: String?,
|
||||
val server: OpenApiSpecServer?
|
||||
)
|
@ -1,8 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecMediaType<T>(
|
||||
val schema: OpenApiSpecReferencable,
|
||||
val examples: Map<String, ExampleWrapper<T>>? = null
|
||||
)
|
||||
|
||||
data class ExampleWrapper<T>(val value: T)
|
@ -1,10 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
import java.net.URI
|
||||
|
||||
data class OpenApiSpecOAuthFlow(
|
||||
val authorizationUrl: URI? = null,
|
||||
val tokenUrl: URI? = null,
|
||||
val refreshUrl: URI? = null,
|
||||
val scopes: Map<String, String>? = null
|
||||
)
|
@ -1,5 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecOAuthFlows(
|
||||
val implicit: OpenApiSpecOAuthFlow?,
|
||||
)
|
@ -1,14 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecPathItem(
|
||||
var get: OpenApiSpecPathItemOperation? = null,
|
||||
var put: OpenApiSpecPathItemOperation? = null,
|
||||
var post: OpenApiSpecPathItemOperation? = null,
|
||||
var delete: OpenApiSpecPathItemOperation? = null,
|
||||
var options: OpenApiSpecPathItemOperation? = null,
|
||||
var head: OpenApiSpecPathItemOperation? = null,
|
||||
var patch: OpenApiSpecPathItemOperation? = null,
|
||||
var trace: OpenApiSpecPathItemOperation? = null,
|
||||
var servers: List<OpenApiSpecServer>? = null,
|
||||
var parameters: List<OpenApiSpecReferencable>? = null
|
||||
)
|
@ -1,19 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecPathItemOperation(
|
||||
var tags: Set<String> = emptySet(),
|
||||
var summary: String? = null,
|
||||
var description: String? = null,
|
||||
var externalDocs: OpenApiSpecExternalDocumentation? = null,
|
||||
var operationId: String? = null,
|
||||
var parameters: List<OpenApiSpecReferencable>? = null,
|
||||
var requestBody: OpenApiSpecReferencable? = null,
|
||||
// TODO How to enforce `default` requirement 🧐
|
||||
var responses: Map<Int, OpenApiSpecReferencable>? = null,
|
||||
var callbacks: Map<String, OpenApiSpecReferencable>? = null,
|
||||
var deprecated: Boolean = false,
|
||||
// todo big yikes... also needs to reference objects in the security scheme 🤔
|
||||
var security: List<Map<String, List<String>>>? = null,
|
||||
var servers: List<OpenApiSpecServer>? = null,
|
||||
var `x-codegen-request-body-name`: String? = null
|
||||
)
|
@ -1,31 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
sealed interface OpenApiSpecReferencable
|
||||
|
||||
data class OpenApiAnyOf(val anyOf: List<OpenApiSpecReferenceObject>) : OpenApiSpecReferencable
|
||||
data class OpenApiSpecReferenceObject(val `$ref`: String) : OpenApiSpecReferencable
|
||||
|
||||
data class OpenApiSpecResponse<T>(
|
||||
val description: String? = null,
|
||||
val headers: Map<String, OpenApiSpecReferencable>? = null,
|
||||
val content: Map<String, OpenApiSpecMediaType<T>>? = null,
|
||||
val links: Map<String, OpenApiSpecReferencable>? = null
|
||||
) : OpenApiSpecReferencable
|
||||
|
||||
data class OpenApiSpecParameter(
|
||||
val name: String,
|
||||
val `in`: String, // TODO Enum? "query", "header", "path" or "cookie"
|
||||
val schema: OpenApiSpecComponentSchema,
|
||||
val description: String? = null,
|
||||
val required: Boolean = true,
|
||||
val deprecated: Boolean = false,
|
||||
val allowEmptyValue: Boolean? = null,
|
||||
val style: String? = null,
|
||||
val explode: Boolean? = null
|
||||
) : OpenApiSpecReferencable
|
||||
|
||||
data class OpenApiSpecRequest<T>(
|
||||
val description: String?,
|
||||
val content: Map<String, OpenApiSpecMediaType<T>>,
|
||||
val required: Boolean = false
|
||||
) : OpenApiSpecReferencable
|
@ -1,11 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecSchemaSecurity(
|
||||
val type: String? = null, // TODO Enum? "apiKey", "http", "oauth2", "openIdConnect"
|
||||
val name: String? = null,
|
||||
val `in`: String? = null,
|
||||
val scheme: String? = null,
|
||||
val flows: OpenApiSpecOAuthFlows? = null,
|
||||
val bearerFormat: String? = null,
|
||||
val description: String? = null,
|
||||
)
|
@ -1,9 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
import java.net.URI
|
||||
|
||||
data class OpenApiSpecServer(
|
||||
val url: URI,
|
||||
val description: String? = null,
|
||||
var variables: Map<String, OpenApiSpecServerVariable>? = null
|
||||
)
|
@ -1,7 +0,0 @@
|
||||
package io.bkbn.kompendium.models.oas
|
||||
|
||||
data class OpenApiSpecTag(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val externalDocs: OpenApiSpecExternalDocumentation? = null
|
||||
)
|
@ -1,13 +0,0 @@
|
||||
package io.bkbn.kompendium.path
|
||||
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.RouteSelector
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
interface IPathCalculator {
|
||||
fun calculate(route: Route?, tail: String = ""): String
|
||||
fun <T : RouteSelector> addCustomRouteHandler(
|
||||
selector: KClass<T>,
|
||||
handler: IPathCalculator.(Route, String) -> String
|
||||
)
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
package io.bkbn.kompendium.path
|
||||
|
||||
import io.ktor.routing.PathSegmentConstantRouteSelector
|
||||
import io.ktor.routing.PathSegmentParameterRouteSelector
|
||||
import io.ktor.routing.RootRouteSelector
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.RouteSelector
|
||||
import io.ktor.routing.TrailingSlashRouteSelector
|
||||
import io.ktor.util.InternalAPI
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Responsible for calculating a url path from a provided [Route]
|
||||
*/
|
||||
@OptIn(InternalAPI::class)
|
||||
internal object PathCalculator: IPathCalculator {
|
||||
|
||||
private val pathHandler: RouteHandlerMap = mutableMapOf()
|
||||
|
||||
init {
|
||||
addCustomRouteHandler(RootRouteSelector::class) { _, tail -> tail }
|
||||
addCustomRouteHandler(PathSegmentParameterRouteSelector::class) { route, tail ->
|
||||
val newTail = "/${route.selector}$tail"
|
||||
calculate(route.parent, newTail)
|
||||
}
|
||||
addCustomRouteHandler(PathSegmentConstantRouteSelector::class) { route, tail ->
|
||||
val newTail = "/${route.selector}$tail"
|
||||
calculate(route.parent, newTail)
|
||||
}
|
||||
addCustomRouteHandler(TrailingSlashRouteSelector::class) { route, tail ->
|
||||
val newTail = tail.ifBlank { "/" }
|
||||
calculate(route.parent, newTail)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalAPI::class)
|
||||
override fun calculate(
|
||||
route: Route?,
|
||||
tail: String
|
||||
): String = when (route) {
|
||||
null -> tail
|
||||
else -> when (pathHandler.containsKey(route.selector::class)) {
|
||||
true -> pathHandler[route.selector::class]!!.invoke(this, route, tail)
|
||||
else -> error("No handler has been registered for ${route.selector}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : RouteSelector> addCustomRouteHandler(
|
||||
selector: KClass<T>,
|
||||
handler: IPathCalculator.(Route, String) -> String
|
||||
) {
|
||||
pathHandler[selector] = handler
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user