Compare commits

...

62 Commits

Author SHA1 Message Date
3c585c06a3 feat: kompendium 2.0.0 release! 2022-01-23 14:14:44 -05:00
7bfd168d74 chore(deps): update endbug/add-and-commit action to v8 (#158)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-21 17:00:22 -05:00
19298f4deb chore(deps): update plugin io.bkbn.sourdough.root to v0.6.0 (#161)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-21 14:46:51 +00:00
54bdf107e2 chore(deps): update plugin io.bkbn.sourdough.library.jvm to v0.6.0 (#160)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-21 12:35:30 +00:00
721302d651 chore(deps): update plugin io.bkbn.sourdough.application.jvm to v0.6.0 (#159)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-21 11:09:16 +00:00
3ffda43a52 chore(deps): update dependency org.webjars:swagger-ui to v4.2.1 (#157)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-20 10:34:17 +00:00
ae2a1b578a chore: version bumps and cleanup (#156) 2022-01-17 03:22:33 +00:00
147c7e7fb0 chore(deps): update kotestversion to v5.1.0 (#155)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-16 19:02:58 -05:00
906b329c2e chore(deps): update dependency org.slf4j:slf4j-simple to v1.7.33 (#153)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-13 19:03:18 +00:00
1bf81cfd82 chore(deps): update dependency org.slf4j:slf4j-api to v1.7.33 (#152)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-13 17:25:57 +00:00
c9f173d6b0 chore(deps): update plugin io.bkbn.sourdough.root to v0.5.5 (#151)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-13 13:19:33 +00:00
c43fafae1b chore(deps): update plugin io.bkbn.sourdough.library.jvm to v0.5.5 (#150)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-13 08:17:21 -05:00
3b2fa72d26 chore(deps): update plugin io.bkbn.sourdough.application.jvm to v0.5.5 (#149)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-13 12:25:52 +00:00
91d4ec10b7 chore(deps): update dependency org.webjars:swagger-ui to v4.1.3-1 (#148)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-13 10:15:55 +00:00
aa1b898b22 fix: adding signing plugin (#147) 2022-01-12 11:19:19 -05:00
aa21c1219b fix: bug in testing suite + lots of documentation updates 2022-01-12 09:15:27 -05:00
bc380077fb chore(deps): update plugin org.jetbrains.kotlin.plugin.serialization to v1.6.10 (#143) 2022-01-09 13:09:51 +00:00
fc9929e9cc chore(deps): update plugin io.bkbn.sourdough.root to v0.3.3 (#142) 2022-01-09 08:05:52 -05:00
a26ad72b67 chore(deps): update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.3.2 (#141)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-09 11:24:03 +00:00
38a70e4979 chore(deps): update dependency org.apache.logging.log4j:log4j-core to v2.17.1 (#140)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-01-09 09:47:01 +00:00
4a1425b73b chore(deps): update dependency org.apache.logging.log4j:log4j-api to v2.17.1 (#138) 2022-01-09 03:13:12 +00:00
01c118373e chore(deps): update dependency gradle to v7.3.3 (#137) 2022-01-08 22:09:03 -05:00
7535d67661 chore(deps): add renovate.json 2022-01-09 02:58:31 +00:00
eb369dcdc8 fix: locations inheritance (#135) 2022-01-07 08:46:20 -05:00
da104d0a63 feat: Multi Serialization Support (#134) 2022-01-04 02:05:30 +00:00
c6ed261fe4 feat: enable creation of explicit parameter examples (#133) 2022-01-03 10:34:02 -05:00
012db5ad26 feat: added head, patch, and options methods (#132) 2022-01-03 14:32:55 +00:00
f02f7ad211 hotfix: hopefully fix bug in release pipeline 2022-01-02 23:34:23 -05:00
c29567114d 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
2022-01-03 04:15:15 +00:00
d66880f9b2 doc: example header parameter (#114) 2021-11-25 23:16:35 +00:00
5e070e1875 feat: Add opt-in locations support via ancillary module (#107) 2021-11-25 13:09:18 -05:00
dd780ad29d feat: add operationId method info (#106) 2021-11-25 15:00:08 +00:00
d2165d23bf update readme 2021-11-13 09:39:35 -05:00
d9d0f129b5 fix: Allow for injectable ObjectMapper to resolve serialization issues for Java 8 date type 2021-10-24 12:25:55 -04:00
c8d56e62a2 fix: code coverage pr check removed 2021-10-17 17:05:36 +00:00
67bd6ad36f Added support for ByteArray type (#88) 2021-10-17 16:43:46 +00:00
1a924058a1 Update README.md 2021-10-04 20:08:25 -04:00
8f81b4d795 Update README.md 2021-10-04 19:58:34 -04:00
9edd3a53ce Update README.md 2021-10-04 19:20:37 -04:00
91a6164663 Codacy integration (#87) 2021-10-04 19:17:25 -04:00
5a7e052ac4 More extensible path calculation (#86) 2021-10-04 18:48:19 -04:00
6ba3617e32 add super hack to support undeclared polymorphic adapter fields (#84) 2021-08-15 01:02:23 +00:00
c32c91829b i forgot to bump the version :(((( 2021-08-12 20:40:09 -05:00
b021935b10 allow custom type overrides (#83) 2021-08-12 21:38:48 -04:00
3d99bf35fd Support polymorphic collections and maps (#82) 2021-08-12 22:32:18 +00:00
c5f8ace5d2 Added support for BigInteger and BigDecimal in responses (#76) 2021-07-25 10:48:14 -04:00
b0149c293e changed jvmTarget version from 11 to 1.8 (#74) 2021-07-22 22:38:39 -04:00
925172cf86 encode oas to json internally (#69) 2021-06-04 20:51:06 +00:00
aa3290243b explicit decoupled serialization (#65) 2021-06-03 09:00:33 -04:00
2e7dad444b Update README.md 2021-05-30 16:02:25 -04:00
6e56bf7425 Update README.md 2021-05-30 16:01:40 -04:00
dfc1593022 Update README.md 2021-05-30 15:57:24 -04:00
5fe98a04e8 Maven Central Here We Come!! (#60) 2021-05-23 13:47:48 -04:00
59c0c3aabf Polymorphic and Generic Support (#59) 2021-05-21 21:35:19 +00:00
c885ff1cfb point well taken, this is a family show (#57) 2021-05-10 13:23:56 +00:00
8c206b7ad6 Update README.md 2021-05-10 08:47:56 -04:00
2bccb1745a i give up, nexus another day i guess (#56) 2021-05-09 16:03:35 +00:00
b66c757c51 fuck u nexy (#55) 2021-05-09 11:54:23 -04:00
1e76a2382d maybe this (#54) 2021-05-09 11:45:09 -04:00
798ff553b5 maybe this (#53) 2021-05-09 11:36:10 -04:00
5b71adc323 maybe (#52) 2021-05-09 11:27:30 -04:00
5a038697bc hopefully will push and release to maven (#51) 2021-05-09 15:22:37 +00:00
251 changed files with 11192 additions and 5642 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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.

View 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.

View File

@ -5,35 +5,27 @@ 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
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
gradle-version: wrapper
arguments: test koverCollectReports

View File

@ -2,6 +2,11 @@ name: Publish to GitHub Packages
on:
push:
branches: [ main ]
paths-ignore:
- docs/**
env:
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SONATYPE_SIGNING_KEY }}
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SONATYPE_SIGNING_PASSWORD }}
jobs:
publish:
runs-on: ubuntu-latest
@ -10,14 +15,11 @@ 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 publish
gradle-version: wrapper
arguments: publishAllPublicationsToGithubPackagesRepository
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,25 +1,53 @@
name: Publish to GitHub Packages
name: Publish Release
on:
release:
types:
- prereleased
- released
env:
ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SONATYPE_SIGNING_KEY }}
ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SONATYPE_SIGNING_PASSWORD }}
jobs:
publish:
publish-to-nexus:
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
java-version: '17'
- name: Publlish to Maven Central
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 publish -Prelease=true
gradle-version: wrapper
arguments: publishToSonatype closeAndReleaseSonatypeStagingRepository
properties: |
release=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.SONATYPE_USER }}
ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }}
build-documentation:
runs-on: ubuntu-latest
needs:
- publish-to-nexus
steps:
- uses: actions/checkout@v2
with:
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
java-version: '17'
- name: Build New Documentation
uses: burrunan/gradle-cache-action@v1
with:
gradle-version: wrapper
arguments: dokkaHtmlMultiModule
properties: |
release=true
- name: Push New Documentation
uses: EndBug/add-and-commit@v8
with:
default_author: github_actions
branch: main
message: 'doc: Added Latest Documentation ✨'

4
.gitignore vendored
View File

@ -1,7 +1,3 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build
.idea

View File

@ -1,3 +0,0 @@
kotlin 1.5.0-M2
java openjdk-14.0.1
gradle 7.0

View File

@ -1,5 +1,200 @@
# Changelog
## Unreleased
### Added
### Changed
### Remove
---
## Released
## [2.0.0] - January 23rd, 2022
Major Release 🎉 As we head towards the Ktor 2 release, this library will be kept compatible with Ktor 1. A future
Kompendium 2 repository will be created soon, porting much of the changes you see here, with some awesome Ktor 2 twists
😉
### Added
- Support for HTTP Patch, Head, and Options methods
- Support for including parameter examples via `MethodInfo`
- Dokka Pipeline Generation
- GitHub Pages integration
- Sourdough Gradle updates
- 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
- Kompendium now leverages the chosen API serializer. Supports Jackson, Gson and Kotlinx Serialization
- Fixed bug where overridden field names were not reflected in serialized object and required array
- Fixed bug where Ktor Location parents were not being scanned for parameters
- `$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
## [1.11.0] - November 25th, 2021
### Added
- Support for Ktor Location Plugin
## [1.10.0] - November 25th, 2021
### Changed
- Added `operationId` parameter to `MethodInfo`
## [1.9.2] - October 24th, 2021
### Changed
- Jackson ObjectMapper passed by parameter to openapi module
- Added serializable annotation to ExceptionResponse
## [1.9.1] - October 17th, 2021
### Changed
- Code Coverage removed from PR checks due to limitations with GitHub workflows
- Minor linting fixes
- Detekt now builds off of default config
## [1.9.0] - october 15th, 2021
### Added
- ByteArray added to the set of default types
## [1.8.1] - October 4th, 2021
### Added
- Codacy integration
## [1.8.0] - October 4th, 2021
### Changed
- Path calculation revamped to allow for simpler selector injection
- Kotlin version bumped to 1.5.31
- Ktor version bumped to 1.6.4
## [1.7.0] - August 14th, 2021
### Added
- Added ability to inject an emergency `UndeclaredField` in the event of certain polymorphic serializers and such
## [1.6.0] - August 12th, 2021
### Added
- Ability to add custom type schema overrides for edge case types.
## [1.5.1] - August 12th, 2021
### Changed
- Fixed bug where polymorphic types were not being rendered correctly when part of collections and maps
## [1.5.0] - July 25th, 2021
### Changed
- Added support for BigInteger and BigDecimal in response types
## [1.4.0] - July 22nd, 2021
### Changed
- Decreased jvmTarget version from 11 to 1.8
## [1.3.0] - June 4th, 2021
### Changed
- Explicitly encode JSON object by default
## [1.2.3] - June 3rd, 2021
### Added
- Updates showing/explaining serializer agnostic approach
## [1.2.2] - May 23rd, 2021
This is just to get my repo back to normal now that I have confirmed sonatype publish is happening
## [1.2.0] - May 23rd, 2021
### Added
- Finally, successfully pushed to Maven Central!!!
## [1.1.0] - May 19th, 2021
### Added
- Support for sealed classes 🔥
- Support for generic classes ☄️
## [1.0.1] - May 10th, 2021
### Changed
- a word to sweep my rude commit message to Nexus under the rug
## [1.0.0] - May 9th, 2021
### Added
- SonaType integration to publish to MavenCentral
### [1.0.0-rc] - May 8th, 2021
### Changed
@ -83,7 +278,8 @@
### Added
- Added an explicit `PathCalculator` interface to allow for easier handling of routes external to the core set of Ktor route selectors.
- Added an explicit `PathCalculator` interface to allow for easier handling of routes external to the core set of Ktor
route selectors.
## [0.5.1] - April 19th, 2021
@ -174,12 +370,13 @@
### Added
- Beginning of an implementation. Currently, able to generate a rough outline of the API at runtime, along with generating
full data classes represented by JSON Schema.
- Beginning of an implementation. Currently, able to generate a rough outline of the API at runtime, along with
generating full data classes represented by JSON Schema.
## [0.0.1] - April 11th, 2021
### Added
- Added _most_ of the data classes necessary for generating an [Open API Spec](https://swagger.io/specification)
- Added playground to allow users to tinker with a live Ktor api in conjunction with development
- Added all standard OSS files

41
Project.md Normal file
View File

@ -0,0 +1,41 @@
# Kompendium
Welcome to Kompendium, the straight-forward, minimally-invasive OpenAPI generator for Ktor.
## How to install
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
repositories {
mavenCentral()
}
dependencies {
implementation("io.bkbn:kompendium-core:latest.release")
}
```
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)
## Setting up the Kompendium Plugin
Kompendium is instantiated as a Ktor Feature/Plugin. It can be added to your API as follows
```kotlin
private fun Application.mainModule() {
// Installs the Kompendium Plugin and sets up baseline server metadata
install(Kompendium) {
spec = OpenApiSpec(/*..*/)
}
// ...
}
```
## Notarization
The concept of notarizing routes / exceptions / etc. is central to Kompendium. More details on _how_ to notarize your
API can be found in the kompendium-core module.

217
README.md
View File

@ -1,210 +1,53 @@
# Kompendium
[![version](https://img.shields.io/maven-central/v/io.bkbn/kompendium-core?style=flat-square)](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
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 uses GitHub packages as its repository. Installing with Gradle is pretty painless. In your `build.gradle.kts`
add the following
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
// 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 { // TODO Not sure this is necessary for public repositories?
username = java.lang.System.getenv("GITHUB_USER")
password = java.lang.System.getenv("GITHUB_TOKEN")
}
}
// 2 Add the repo in question (in this case Kompendium)
repositories {
github("https://maven.pkg.github.com/bkbnio/kompendium")
mavenCentral()
}
// 3 Add the package like any normal dependency
dependencies {
implementation("org.leafygreens:kompendium-core:0.1.0-SNAPSHOT")
}
```
## In depth
### Notarized Routes
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.
## 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",
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()
implementation("io.bkbn:kompendium-core:latest.release")
}
```
### 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
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
routing {
openApi(oas)
redoc(oas)
}
```
# Library Details
## Limitations
Forthcoming, more details on V2 will be published soon :)
### Kompendium as a singleton
## Local Development
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.
Kompendium should run locally right out of the box, no configuration necessary (assuming you have JDK 1.8+ installed).
New features can be built locally and published to your local maven repository with the `./gradlew publishToMavenLocal`
command!
If this is a blocker, please open a GitHub issue, and we can start to think out solutions!
## The Playground
## 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
This repo contains a `playground` module that contains a number of working examples showcasing the capabilities of
Kompendium.
- Polymorphic support
- AsyncAPI Integration
- Field Validation
- MavenCentral Release
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)
Feel free to check it out, or even create your own example!

View File

@ -1,62 +1,46 @@
plugins {
id("org.jetbrains.kotlin.jvm") version "1.4.32" apply false
id("io.gitlab.arturbosch.detekt") version "1.16.0-RC2" apply false
id("com.adarshr.test-logger") version "3.0.0" apply false
kotlin("jvm") version "1.6.10" apply false
kotlin("plugin.serialization") version "1.6.10" apply false
id("io.bkbn.sourdough.library.jvm") version "0.6.0" apply false
id("io.bkbn.sourdough.application.jvm") version "0.6.0" apply false
id("io.bkbn.sourdough.root") version "0.6.0"
id("com.github.jakemarsden.git-hooks") version "0.0.2"
id("org.jetbrains.dokka") version "1.6.10"
id("org.jetbrains.kotlinx.kover") version "0.5.0-RC"
id("io.github.gradle-nexus.publish-plugin") version "1.1.0"
}
gitHooks {
setHooks(
mapOf(
"pre-commit" to "detekt",
"pre-push" to "test"
)
)
}
allprojects {
group = "io.bkbn"
version = run {
val baseVersion =
project.findProperty("project.version") ?: error("project.version must be set in gradle.properties")
project.findProperty("project.version") ?: error("project.version needs to be set in gradle.properties")
when ((project.findProperty("release") as? String)?.toBoolean()) {
true -> baseVersion
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")
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = "11"
subprojects {
plugins.withType(io.bkbn.sourdough.gradle.library.jvm.LibraryJvmPlugin::class) {
extensions.configure(io.bkbn.sourdough.gradle.library.jvm.LibraryJvmExtension::class) {
githubOrg.set("bkbnio")
githubRepo.set("kompendium")
licenseName.set("MIT License")
licenseUrl.set("https://mit-license.org")
developerId.set("unredundant")
developerName.set("Ryan Brink")
developerEmail.set("admin@bkbn.io")
}
}
configure<com.adarshr.gradle.testlogger.TestLoggerExtension> {
setTheme("standard")
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<io.gitlab.arturbosch.detekt.extensions.DetektExtension> {
toolVersion = "1.16.0-RC2"
config = files("${rootProject.projectDir}/detekt.yml")
buildUponDefaultConfig = true
}
configure<JavaPluginExtension> {
withSourcesJar()
}
}

3
codacy.yml Normal file
View File

@ -0,0 +1,3 @@
---
exclude_paths:
- "**/*.md"

View File

@ -1,710 +1,25 @@
build:
maxIssues: 0
excludeCorrectable: false
weights:
# complexity: 2
# LongParameterList: 1
# style: 1
# comments: 1
config:
validation: true
warningsAsErrors: false
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
excludes: ''
processors:
active: true
exclude:
- 'DetektProgressListener'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FileBasedFindingsReport'
output-reports:
active: true
comments:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
AbsentOrWrongFileLicense:
active: false
licenseTemplateFile: 'license.template'
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
UndocumentedPublicClass:
active: false
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
UndocumentedPublicFunction:
active: false
UndocumentedPublicProperty:
active: false
complexity:
active: true
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
TooManyFunctions:
active: false
threshold: 10
includeStaticDeclarations: false
includePrivateDeclarations: false
ComplexMethod:
active: true
threshold: 25
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions: [run, let, apply, with, also, use, forEach, isNotNull, ifNull]
LabeledExpression:
active: false
ignoredLabels: []
LargeClass:
active: true
threshold: 600
LongMethod:
active: true
threshold: 80
LongParameterList:
active: true
functionThreshold: 10
constructorThreshold: 10
ignoreDefaultParameters: false
ignoreDataClasses: true
ignoreAnnotated: []
MethodOverloading:
active: false
threshold: 6
NamedArguments:
active: false
threshold: 3
NestedBlockDepth:
active: true
threshold: 6
ReplaceSafeCallChainWithRun:
active: false
StringLiteralDuplication:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
coroutines:
active: true
GlobalCoroutineUsage:
active: false
RedundantSuspendModifier:
active: false
SleepInsteadOfDelay:
active: false
SuspendFunWithFlowReturnType:
active: false
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: '_|(ignore|expected).*'
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: false
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyTryBlock:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: true
methodNames: [toString, hashCode, equals, finalize]
InstanceOfCheckForException:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
NotImplementedDeclaration:
active: false
ObjectExtendsThrowable:
active: false
PrintStackTrace:
active: true
RethrowCaughtException:
active: true
ReturnFromFinally:
active: true
ignoreLabeled: false
SwallowedException:
active: true
ignoredExceptionTypes:
- InterruptedException
- NumberFormatException
- ParseException
- MalformedURLException
allowedExceptionNameRegex: '_|(ignore|expected).*'
ThrowingExceptionFromFinally:
active: true
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
exceptions:
- IllegalArgumentException
- IllegalStateException
- IOException
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
exceptionNames:
- ArrayIndexOutOfBoundsException
- Error
- Exception
- IllegalMonitorStateException
- NullPointerException
- IndexOutOfBoundsException
- RuntimeException
- Throwable
allowedExceptionNameRegex: '_|(ignore|expected).*'
TooGenericExceptionThrown:
active: true
exceptionNames:
- Error
- Exception
- Throwable
- RuntimeException
ComplexMethod:
threshold: 20
formatting:
active: true
android: false
autoCorrect: true
AnnotationOnSeparateLine:
active: false
autoCorrect: true
AnnotationSpacing:
active: false
autoCorrect: true
ArgumentListWrapping:
active: false
autoCorrect: true
ChainWrapping:
active: true
autoCorrect: true
CommentSpacing:
active: true
autoCorrect: true
EnumEntryNameCase:
active: false
autoCorrect: true
Filename:
active: true
FinalNewline:
active: true
autoCorrect: true
insertFinalNewLine: true
ImportOrdering:
active: false
autoCorrect: true
layout: 'idea'
Indentation:
active: false
autoCorrect: true
indentSize: 4
continuationIndentSize: 4
MaximumLineLength:
active: true
maxLineLength: 120
ModifierOrdering:
active: true
autoCorrect: true
MultiLineIfElse:
active: true
autoCorrect: true
NoBlankLineBeforeRbrace:
active: true
autoCorrect: true
NoConsecutiveBlankLines:
active: true
autoCorrect: true
NoEmptyClassBody:
active: true
autoCorrect: true
NoEmptyFirstLineInMethodBlock:
active: false
autoCorrect: true
NoLineBreakAfterElse:
active: true
autoCorrect: true
NoLineBreakBeforeAssignment:
active: true
autoCorrect: true
NoMultipleSpaces:
active: true
autoCorrect: true
NoSemicolons:
active: true
autoCorrect: true
NoTrailingSpaces:
active: true
autoCorrect: true
NoUnitReturn:
active: true
autoCorrect: true
NoUnusedImports:
active: true
autoCorrect: true
NoWildcardImports:
active: true
PackageName:
active: true
autoCorrect: true
ParameterListWrapping:
active: false
autoCorrect: true
indentSize: 4
SpacingAroundAngleBrackets:
active: false
autoCorrect: true
SpacingAroundColon:
active: true
autoCorrect: true
SpacingAroundComma:
active: true
autoCorrect: true
SpacingAroundCurly:
active: true
autoCorrect: true
SpacingAroundDot:
active: true
autoCorrect: true
SpacingAroundDoubleColon:
active: false
autoCorrect: true
SpacingAroundKeyword:
active: true
autoCorrect: true
SpacingAroundOperators:
active: true
autoCorrect: true
SpacingAroundParens:
active: true
autoCorrect: true
SpacingAroundRangeOperator:
active: true
autoCorrect: true
SpacingAroundUnaryOperator:
active: false
autoCorrect: true
SpacingBetweenDeclarationsWithAnnotations:
active: false
autoCorrect: true
SpacingBetweenDeclarationsWithComments:
active: false
autoCorrect: true
StringTemplate:
active: true
autoCorrect: true
naming:
active: true
ClassNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
classPattern: '[A-Z][a-zA-Z0-9]*'
ConstructorParameterNaming:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
ignoreOverridden: true
EnumNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
forbiddenName: []
FunctionMaxLength:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)'
excludeClassPattern: '$^'
ignoreOverridden: true
ignoreAnnotated: ['Composable']
FunctionParameterNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
parameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
ignoreOverridden: true
InvalidPackageDeclaration:
active: false
excludes: ['*.kts']
rootPackage: ''
MatchingDeclarationName:
active: true
mustBeFirst: true
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
NonBooleanPropertyPrefixedWithIs:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
ObjectPropertyNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
TopLevelPropertyNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
maximumVariableNameLength: 64
VariableMinLength:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
minimumVariableNameLength: 1
VariableNaming:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
variablePattern: '[a-z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
ignoreOverridden: true
performance:
active: true
ArrayPrimitive:
active: true
ForEachOnRange:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
SpreadOperator:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
Deprecation:
active: false
DontDowncastCollectionTypes:
active: false
DuplicateCaseInWhenExpression:
active: true
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExitOutsideMain:
active: false
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: false
IgnoredReturnValue:
active: false
restrictToAnnotatedMethods: true
returnValueAnnotations: ['*.CheckReturnValue', '*.CheckResult']
ImplicitDefaultLocale:
active: true
ImplicitUnitReturnType:
active: false
allowExplicitReturnType: true
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: false
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
excludeAnnotatedProperties: []
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
active: false
MissingWhenCase:
active: true
allowElseExpression: true
NullableToStringCall:
active: false
RedundantElseInWhen:
active: true
UnconditionalJumpStatementInLoop:
active: false
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnreachableCatchBlock:
active: false
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: true
UnsafeCast:
active: true
UselessPostfixExpression:
active: false
WrongEqualsTypeParameter:
active: true
style:
active: true
ClassOrdering:
active: false
CollapsibleIfStatements:
active: false
DataClassContainsFunctions:
active: false
conversionFunctionPrefix: 'to'
DataClassShouldBeImmutable:
active: false
DestructuringDeclarationWithTooManyEntries:
active: false
maxDestructuringEntries: 3
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: false
ExplicitCollectionElementAccessMethod:
active: false
ExplicitItLambdaParameter:
active: false
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenComment:
active: true
values: ['TODO:', 'FIXME:', 'STOPSHIP:']
allowedPatterns: ''
ForbiddenImport:
active: false
imports: []
forbiddenPatterns: ''
ForbiddenMethodCall:
active: false
methods: ['kotlin.io.println', 'kotlin.io.print']
ForbiddenPublicDataClass:
active: true
excludes: ['**']
ignorePackages: ['*.internal', '*.internal.*']
ForbiddenVoid:
active: false
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
ignoreActualFunction: true
excludedFunctions: 'describeContents'
excludeAnnotatedFunction: ['dagger.Provides']
LibraryCodeMustSpecifyReturnType:
active: true
excludes: ['**']
LibraryEntitiesShouldNotBePublic:
active: true
excludes: ['**']
LoopWithTooManyJumpStatements:
active: true
maxJumpCount: 1
MagicNumber:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
ignoreNumbers: ['-1', '0', '1', '2']
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
MandatoryBracesIfStatements:
active: false
MandatoryBracesLoops:
active: false
MaxLineLength:
excludes: ['**/test/**/*']
active: true
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: false
MayBeConst:
active: true
ModifierOrder:
active: true
MultilineLambdaItParameter:
MagicNumber:
excludes: ['**/kompendium-playground/**/*', '**/test/**/*']
naming:
ConstructorParameterNaming:
active: false
NestedClassesVisibility:
active: true
NewLineAtEndOfFile:
active: true
NoTabs:
performance:
SpreadOperator:
active: false
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: false
OptionalWhenBraces:
active: false
PreferToOverPairSyntax:
active: false
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: false
RedundantHigherOrderMapUsage:
active: false
RedundantVisibilityModifierRule:
active: false
ReturnCount:
active: true
max: 2
excludedFunctions: 'equals'
excludeLabeled: false
excludeReturnFromLambda: true
excludeGuardClauses: false
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: true
SpacingBetweenPackageAndImports:
active: false
ThrowsCount:
active: true
max: 2
TrailingWhitespace:
active: false
UnderscoresInNumericLiterals:
active: false
acceptableDecimalLength: 5
UnnecessaryAbstractClass:
active: true
excludeAnnotatedClasses: ['dagger.Module']
UnnecessaryAnnotationUseSiteTarget:
active: false
UnnecessaryApply:
active: true
UnnecessaryFilter:
active: false
UnnecessaryInheritance:
active: true
UnnecessaryLet:
active: false
UnnecessaryParentheses:
active: false
UntilInsteadOfRangeTo:
active: false
UnusedImports:
active: false
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: true
allowedNames: '(_|ignored|expected|serialVersionUID)'
UseArrayLiteralsInAnnotations:
active: false
UseCheckNotNull:
active: false
UseCheckOrError:
active: false
UseDataClass:
active: false
excludeAnnotatedClasses: []
allowVars: false
UseEmptyCounterpart:
active: false
UseIfEmptyOrIfBlank:
active: false
UseIfInsteadOfWhen:
active: false
UseIsNullOrEmpty:
active: false
UseRequire:
active: false
UseRequireNotNull:
active: false
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: true
WildcardImport:
active: true
excludes: ['**/test/**', '**/testIntegration/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
excludeImports: ['java.util.*', 'kotlinx.android.synthetic.*']

View File

@ -1,7 +1,12 @@
# Kompendium
project.version=1.0.0-rc
project.version=2.0.0
# 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.1.0

View File

@ -1,29 +0,0 @@
[versions]
kotlin = "1.4.32"
ktor = "1.5.3"
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-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" }
# 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" }
[bundles]
ktor = [ "ktor-server-core", "ktor-server-netty", "ktor-jackson", "ktor-html-builder" ]
ktorAuth = [ "ktor-auth-lib", "ktor-auth-jwt" ]
logging = [ "slf4j", "logback-classic", "logback-core" ]

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

269
gradlew vendored
View File

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

View File

@ -0,0 +1,13 @@
# 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
# Package io.bkbn.kompendium.annotations.constraint
Annotations that place bespoke constraints on individual fields of your API schemas.

View File

@ -0,0 +1,23 @@
plugins {
kotlin("jvm")
id("io.bkbn.sourdough.library.jvm")
id("io.gitlab.arturbosch.detekt")
id("com.adarshr.test-logger")
id("org.jetbrains.dokka")
id("maven-publish")
id("java-library")
id("signing")
}
sourdough {
libraryName.set("Kompendium Annotations")
libraryDescription.set("A set of annotations used by Kompendium to generate OpenAPI Specifications")
}
testing {
suites {
named("test", JvmTestSuite::class) {
useJUnitJupiter()
}
}
}

View File

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

View File

@ -2,4 +2,4 @@ package io.bkbn.kompendium.annotations
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class KompendiumField(val name: String)
annotation class FreeFormObject

View File

@ -0,0 +1,9 @@
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]
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class Param(val type: ParamType)

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class Format(val format: String)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MaxItems(val items: Int)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MaxLength(val length: Int)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MaxProperties(val properties: Int)

View File

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

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MinItems(val items: Int)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MinLength(val length: Int)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MinProperties(val properties: Int)

View File

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

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class MultipleOf(val multiple: String)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class Pattern(val pattern: String)

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.annotations.constraint
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class UniqueItems

13
kompendium-auth/Module.md Normal file
View File

@ -0,0 +1,13 @@
# 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.
# Package io.bkbn.kompendium.auth
Houses the available security configurations. At the moment, `Basic`, `JWT`, `ApiKey`, and `OAuth` are supported

View File

@ -1,40 +1,37 @@
plugins {
`java-library`
`maven-publish`
kotlin("jvm")
id("io.bkbn.sourdough.library.jvm")
id("io.gitlab.arturbosch.detekt")
id("com.adarshr.test-logger")
id("org.jetbrains.dokka")
id("maven-publish")
id("java-library")
id("signing")
}
sourdough {
libraryName.set("Kompendium Authentication")
libraryDescription.set("Kompendium library to pair with Ktor Auth to provide authorization info to OpenAPI")
}
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("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0")
testImplementation("io.ktor:ktor-server-test-host:1.5.3")
// TESTING
testImplementation(testFixtures(projects.kompendiumCore))
}
java {
withSourcesJar()
}
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)
testing {
suites {
named("test", JvmTestSuite::class) {
useJUnitJupiter()
}
}
}

View File

@ -1,19 +0,0 @@
package io.bkbn.kompendium.auth
import io.ktor.auth.AuthenticationRouteSelector
import io.ktor.routing.Route
import io.bkbn.kompendium.path.CorePathCalculator
import org.slf4j.LoggerFactory
class AuthPathCalculator : CorePathCalculator() {
private val logger = LoggerFactory.getLogger(javaClass)
override fun handleCustomSelectors(route: Route, tail: String): String = when (route.selector) {
is AuthenticationRouteSelector -> {
logger.debug("Found authentication route selector ${route.selector}")
super.calculate(route.parent, tail)
}
else -> super.handleCustomSelectors(route, tail)
}
}

View File

@ -1,50 +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
object KompendiumAuth {
init {
Kompendium.pathCalculator = AuthPathCalculator()
}
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)?
}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
package io.bkbn.kompendium.auth.configuration
interface BasicAuthConfiguration : SecurityConfiguration

View File

@ -0,0 +1,6 @@
package io.bkbn.kompendium.auth.configuration
interface JwtAuthConfiguration : SecurityConfiguration {
val bearerFormat: String
get() = "JWT"
}

View File

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

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.auth.configuration
sealed interface SecurityConfiguration {
val name: String
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,38 @@
{
"openapi": "3.0.3",
"info" : { },
"servers" : [ ],
"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" : [ {
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
@ -16,7 +40,8 @@
},
"required": true,
"deprecated": false
}, {
},
{
"name": "aa",
"in": "query",
"schema": {
@ -25,44 +50,38 @@
},
"required": true,
"deprecated": false
} ],
}
],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref" : "#/components/schemas/TestResponse"
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
],
"type": "object"
}
}
}
}
},
"deprecated": false,
"security" : [ {
"security": [
{
"basic": []
} ]
}
]
}
}
},
"components": {
"schemas" : {
"String" : {
"type" : "string"
},
"TestResponse" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes": {
"basic": {
"type": "http",

View File

@ -1,14 +1,38 @@
{
"openapi": "3.0.3",
"info" : { },
"servers" : [ ],
"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" : [ {
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
@ -16,7 +40,8 @@
},
"required": true,
"deprecated": false
}, {
},
{
"name": "aa",
"in": "query",
"schema": {
@ -25,46 +50,41 @@
},
"required": true,
"deprecated": false
} ],
}
],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref" : "#/components/schemas/TestResponse"
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
],
"type": "object"
}
}
}
}
},
"deprecated": false,
"security" : [ {
"security": [
{
"jwt": []
} ]
}
]
}
}
},
"components": {
"schemas" : {
"String" : {
"type" : "string"
},
"TestResponse" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes": {
"jwt": {
"bearerFormat": "JWT",
"type": "http",
"scheme": "bearer"
}

View File

@ -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" : {
"format" : "int32",
"type" : "integer"
},
"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" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes" : {
"jwt" : {
"type" : "apiKey",
"name" : "x-api-key",
"in" : "header"
}
}
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -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" : {
"format" : "int32",
"type" : "integer"
},
"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" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes" : {
"jwt" : {
"type" : "http",
"scheme" : "oauth"
}
}
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -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" : {
"format" : "int32",
"type" : "integer"
},
"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" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes" : {
"jwt1" : {
"type" : "apiKey",
"name" : "x-api-key-1",
"in" : "header"
},
"jwt2" : {
"type" : "apiKey",
"name" : "x-api-key-2",
"in" : "header"
}
}
},
"security" : [ ],
"tags" : [ ]
}

View File

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

100
kompendium-core/Module.md Normal file
View File

@ -0,0 +1,100 @@
# 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, including the Kompendium Ktor Plugin, route
notarization methods, and the reflection engine that analyzes method info type parameters.
## Plugin
The Kompendium plugin is an extremely light-weight plugin, with only a couple areas of customization.
### Serialization
Kompendium relies on your API to provide a properly-configured `ContentNegotiator` in order to convert the `OpenApiSpec`
into JSON. The advantage to this approach is that all of your data classes will be serialized precisely how you define.
The downside is that issues could exist in serialization frameworks that have not been tested. At the moment, Jackson,
Gson and KotlinX serialization have all been tested. If you run into any serialization issues, particularly with a
serializer not listed above, please open an issue on GitHub 🙏
## Notarization
Central to Kompendium is the concept of notarization.
Notarizing a route is the mechanism by which Kompendium analyzes your route types, along with provided metadata, and
converts to the expected OpenAPI format.
Before jumping into notarization, lets first look at a standard Ktor route
```kotlin
routing {
get {
call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
}
}
```
Now, let's compare this to the same functionality, but notarized using Kompendium
```kotlin
routing {
notarizedGet(simpleGetExample) {
call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
}
}
```
Pretty simple huh. But hold on... what is this `simpleGetExample`? How can I know that it is so "simple". Let's take a
look
```kotlin
val simpleGetExample = GetInfo<Unit, BasicResponse>(
summary = "Simple, Documented GET Request",
description = "This is to showcase just how easy it is to document your Ktor API!",
responseInfo = ResponseInfo(
status = HttpStatusCode.OK,
description = "This means everything went as expected!",
examples = mapOf("demo" to BasicResponse(c = "52c099d7-8642-46cc-b34e-22f39b923cf4"))
),
tags = setOf("Simple")
)
```
See, not so bad 😄 `GetInfo<*,*>` is an implementation of `MethodInfo<TParam, TResp>`, a sealed interface designed to
encapsulate all the metadata required for documenting an API route. Kompendium leverages this data, along with the
provided type parameters `TParam` and `TResp` to construct the full OpenAPI Specification for your route.
Additionally, just as a backup, each notarization method includes a "post-processing' hook that will allow you to have
final say in the generated route info prior to being attached to the spec. This can be accessed via the optional
parameter
```kotlin
routing {
notarizedGet(simpleGetExample, postProcess = { spec -> spec }) {
call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
}
}
```
This should only be used in _extremely_ rare scenarios, but it is nice to know it is there if you need it.
# Package io.bkbn.kompendium.core.metadata
Houses all interfaces and types related to describing route metadata.
# Package io.bkbn.kompendium.core.parser
Responsible for the parse of method information. Base implementation is an interface to support extensibility as shown
in the `kompendium-locations` module.
# Package io.bkbn.kompendium.core.routes
Houses any routes provided by the core module. At the moment the only supported route is to enable ReDoc support.
# Package io.bkbn.kompendium.core.util
Collection of utility functions used by Kompendium

View File

@ -1,37 +1,54 @@
plugins {
`java-library`
`maven-publish`
kotlin("jvm")
id("io.bkbn.sourdough.library.jvm")
id("io.gitlab.arturbosch.detekt")
id("com.adarshr.test-logger")
id("org.jetbrains.dokka")
id("maven-publish")
id("java-library")
id("signing")
id("java-test-fixtures")
}
sourdough {
libraryName.set("Kompendium Core")
libraryDescription.set("Core functionality for the Kompendium library")
compilerArgs.set(listOf("-opt-in=kotlin.RequiresOptIn"))
}
dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation(libs.bundles.ktor)
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0")
testImplementation("io.ktor:ktor-server-test-host:1.5.3")
// VERSIONS
val ktorVersion: String by project
val kotestVersion: String by project
// IMPLEMENTATION
api(projects.kompendiumOas)
api(projects.kompendiumAnnotations)
implementation(group = "io.ktor", name = "ktor-server-core", version = ktorVersion)
implementation(group = "io.ktor", name = "ktor-html-builder", version = ktorVersion)
// 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.2")
}
java {
withSourcesJar()
}
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)
testing {
suites {
named("test", JvmTestSuite::class) {
useJUnitJupiter()
}
}
}

View File

@ -1,34 +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.path.CorePathCalculator
import io.bkbn.kompendium.path.PathCalculator
/**
* 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()
)
var pathCalculator: PathCalculator = CorePathCalculator()
fun resetSchema() {
openApiSpec = OpenApiSpec(
info = OpenApiSpecInfo(),
servers = mutableListOf(),
paths = mutableMapOf()
)
cache = emptyMap()
}
}

View File

@ -1,50 +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 {
Kompendium.cache = Kontent.generateKontent<TResp>(Kompendium.cache)
Kompendium.cache = Kontent.generateKontent<TReq>(Kompendium.cache)
Kompendium.cache = Kontent.generateParameterKontent<TParam>(Kompendium.cache)
Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache)
val requestType = typeOf<TReq>()
val responseType = typeOf<TResp>()
val paramType = typeOf<TParam>()
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
) {
Kompendium.cache = Kontent.generateKontent<TResp>(Kompendium.cache)
Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache)
val errorType = typeOf<TErr>()
val responseType = typeOf<TResp>()
return block.invoke(errorType, responseType)
}
}

View File

@ -1,168 +0,0 @@
package io.bkbn.kompendium
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import kotlin.reflect.typeOf
import io.bkbn.kompendium.models.meta.SchemaMap
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.logged
import org.slf4j.LoggerFactory
/**
* 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)
}
/**
* 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 }
}
/**
* 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"))
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(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
*/
private fun handleComplexType(clazz: KClass<*>, cache: SchemaMap): SchemaMap =
when (cache.containsKey(clazz.simpleName)) {
true -> {
logger.debug("Cache already contains ${clazz.simpleName}, returning cache untouched")
cache
}
false -> {
logger.debug("${clazz.simpleName} was not found in cache, generating now")
var newCache = cache
val fieldMap = clazz.memberProperties.associate { prop ->
logger.debug("Analyzing $prop in class $clazz")
val field = prop.javaField?.type?.kotlin ?: error("Unable to parse field type from $prop")
logger.debug("Detected field $field")
if (!newCache.containsKey(field.simpleName)) {
logger.debug("Cache was missing ${field.simpleName}, adding now")
newCache = generateKTypeKontent(prop.returnType, newCache)
}
val propSchema = ReferencedSchema(field.getReferenceSlug(prop))
Pair(prop.name, propSchema)
}
logger.debug("${clazz.simpleName} contains $fieldMap")
val schema = ObjectSchema(fieldMap)
logger.debug("${clazz.simpleName} schema: $schema")
newCache.plus(clazz.simpleName!! 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 valClassName = (valType?.classifier as KClass<*>).simpleName
val referenceName = genericNameAdapter(type, clazz)
val valueReference = ReferencedSchema("$COMPONENT_SLUG/$valClassName")
val schema = DictionarySchema(additionalProperties = valueReference)
val updatedCache = generateKTypeKontent(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 = ReferencedSchema("${COMPONENT_SLUG}/${collectionClass.simpleName}")
val schema = ArraySchema(items = valueReference)
val updatedCache = generateKTypeKontent(collectionType, cache)
return updatedCache.plus(referenceName to schema)
}
}

View File

@ -1,224 +0,0 @@
package io.bkbn.kompendium
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.KProperty
import kotlin.reflect.KType
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 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.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
/**
* The MethodParser is responsible for converting route metadata and types into an OpenAPI compatible data class.
*/
object MethodParser {
/**
* Generates the OpenAPI Path spec from provided metadata
* @param info implementation of the [MethodInfo] sealed class
* @param paramType Type of `TParam`
* @param requestType Type of `TReq` if required
* @param responseType Type of `TResp`
* @return object representing the OpenAPI Path spec.
*/
fun parseMethodInfo(
info: MethodInfo<*, *>,
paramType: KType,
requestType: KType,
responseType: KType
) = OpenApiSpecPathItemOperation(
summary = info.summary,
description = info.description,
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))
}
},
requestBody = when (info) {
is MethodInfo.PutInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo)
is MethodInfo.PostInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo)
else -> null
},
security = if (info.securitySchemes.isNotEmpty()) listOf(
// TODO support scopes
info.securitySchemes.associateWith { listOf() }
) 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))
}
/**
* 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]
* @receiver [KType] to convert
* @param requestInfo request metadata
* @return Will return a generated [OpenApiSpecRequest] if requestInfo is not null
*/
private fun KType.toRequestSpec(requestInfo: RequestInfo<*>?): OpenApiSpecRequest<*>? =
when (requestInfo) {
null -> null
else -> {
OpenApiSpecRequest(
description = requestInfo.description,
content = resolveContent(requestInfo.mediaTypes, requestInfo.examples) ?: mapOf()
)
}
}
/**
* Converts a [KType] to a pairing of http status code to [OpenApiSpecRequest]
* @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<*>>? =
when (responseInfo) {
null -> null
else -> {
val specResponse = OpenApiSpecResponse(
description = responseInfo.description,
content = resolveContent(responseInfo.mediaTypes, responseInfo.examples)
)
Pair(responseInfo.status.value, specResponse)
}
}
/**
* Generates MediaTypes along with any examples provided
* @receiver [KType] Type of the object
* @param mediaTypes list of acceptable http media types
* @param examples Mapping of named examples of valid bodies.
* @return Named mapping of media types.
*/
private fun <F> KType.resolveContent(
mediaTypes: List<String>,
examples: Map<String, F>
): Map<String, OpenApiSpecMediaType<F>>? {
return if (this != Helpers.UNIT_TYPE && mediaTypes.isNotEmpty()) {
mediaTypes.associateWith {
val ref = getReferenceSlug()
OpenApiSpecMediaType(
schema = OpenApiSpecReferenceObject(ref),
examples = examples.mapValues { (_, v) -> ExampleWrapper(v) }.ifEmpty { null }
)
}
} else null
}
/**
* Parses a type for all parameter information. All fields in the receiver
* must be annotated with [org.leafygreens.kompendium.annotations.KompendiumParam].
* @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> {
val clazz = classifier as KClass<*>
return clazz.memberProperties.map { prop ->
val field = prop.javaField?.type?.kotlin
?: error("Unable to parse field type from $prop")
val anny = prop.findAnnotation<KompendiumParam>()
?: error("Field ${prop.name} is not annotated with KompendiumParam")
val schema = Kompendium.cache[field.getSimpleSlug(prop)]
?: error("Could not find component type for $prop")
val defaultValue = getDefaultParameterValue(clazz, prop)
OpenApiSpecParameter(
name = prop.name,
`in` = anny.type.name.toLowerCase(),
schema = schema.addDefault(defaultValue),
description = anny.description.ifBlank { null },
required = !prop.returnType.isMarkedNullable
)
}
}
/**
* Absolutely disgusting reflection to determine if a default value is available for a given property.
* @param clazz to which the property belongs
* @param prop the property in question
* @return The default value if found
*/
private fun getDefaultParameterValue(clazz: KClass<*>, prop: KProperty<*>): Any? {
val constructor = clazz.primaryConstructor
val parameterInQuestion = constructor
?.parameters
?.find { it.name == prop.name }
?: error("could not find parameter ${prop.name}")
if (!parameterInQuestion.isOptional) {
return null
}
val values = constructor
.parameters
.filterNot { it.isOptional }
.associateWith { defaultValueInjector(it) }
val instance = constructor.callBy(values)
val methods = clazz.java.methods
val getterName = "get${prop.name.capitalize()}"
val getterFunction = methods.find { it.name == getterName }
?: error("Could not associate ${prop.name} with a getter")
return getterFunction.invoke(instance)
}
/**
* Allows the reflection invoker to populate a parameter map with values in order to sus out any default parameters.
* @param param Parameter to provide value for
* @return value of the proper type to match param
* @throws [IllegalStateException] if parameter type is not one of the basic types supported below.
*/
private fun defaultValueInjector(param: KParameter): Any = when (param.type.classifier) {
String::class -> "test"
Boolean::class -> false
Int::class -> 1
Long::class -> 2
Double::class -> 1.0
Float::class -> 1.0
UUID::class -> UUID.randomUUID()
else -> error("Unsupported Type")
}
}

View File

@ -1,116 +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 @[org.leafygreens.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.pathCalculator.calculate(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 @[org.leafygreens.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.pathCalculator.calculate(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 @[org.leafygreens.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.pathCalculator.calculate(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 @[org.leafygreens.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.pathCalculator.calculate(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)
}
}

View File

@ -1,12 +0,0 @@
package io.bkbn.kompendium.annotations
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class KompendiumParam(val type: ParamType, val description: String = "")
enum class ParamType {
COOKIE,
HEADER,
PATH,
QUERY
}

View File

@ -0,0 +1,44 @@
package io.bkbn.kompendium.core
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.respond
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)
}
}
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.respond(HttpStatusCode.OK, configuration.spec)
}
}
return Kompendium(configuration)
}
}
}

View File

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

View File

@ -0,0 +1,467 @@
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()
// todo de-dup this logic
if (requiredParams.isNotEmpty()) {
schema = schema.copy(required = requiredParams.map { param ->
clazz.memberProperties.first { it.name == param.name }.findAnnotation<Field>()
?.let { field -> field.name.ifBlank { param.name!! } }
?: param.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
// todo dedup this
if (requiredParams.isNotEmpty()) {
schema = schema.copy(required = requiredParams.map { param ->
clazz.memberProperties.first { it.name == param.name }.findAnnotation<Field>()
?.let { field -> field.name.ifBlank { param.name!! } }
?: param.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)
}
}

View File

@ -0,0 +1,172 @@
package io.bkbn.kompendium.core
import io.bkbn.kompendium.annotations.Param
import io.bkbn.kompendium.core.KompendiumPreFlight.methodNotarizationPreFlight
import io.bkbn.kompendium.core.metadata.method.DeleteInfo
import io.bkbn.kompendium.core.metadata.method.GetInfo
import io.bkbn.kompendium.core.metadata.method.HeadInfo
import io.bkbn.kompendium.core.metadata.method.OptionsInfo
import io.bkbn.kompendium.core.metadata.method.PatchInfo
import io.bkbn.kompendium.core.metadata.method.PostInfo
import io.bkbn.kompendium.core.metadata.method.PutInfo
import io.bkbn.kompendium.core.parser.DefaultMethodParser.calculateRoutePath
import io.bkbn.kompendium.core.parser.DefaultMethodParser.parseMethodInfo
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 PUT 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 PATCH 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.notarizedPatch(
info: PatchInfo<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]?.patch = postProcess(baseInfo)
return method(HttpMethod.Patch) { handle(body) }
}
/**
* Notarization for an HTTP DELETE 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) }
}
/**
* Notarization for an HTTP HEAD request
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
* @param info Route metadata
* @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
*/
inline fun <reified TParam : Any> Route.notarizedHead(
info: HeadInfo<TParam>,
postProcess: (PathOperation) -> PathOperation = { p -> p },
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = methodNotarizationPreFlight<TParam, Unit, Unit> { 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]?.head = postProcess(baseInfo)
return method(HttpMethod.Head) { handle(body) }
}
/**
* Notarization for an HTTP OPTION 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.notarizedOptions(
info: OptionsInfo<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]?.options = postProcess(baseInfo)
return method(HttpMethod.Options) { handle(body) }
}
}

View File

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

View File

@ -0,0 +1,3 @@
package io.bkbn.kompendium.core.metadata
data class ParameterExample(val parameterName: String, val exampleName: String, val exampleValue: Any)

View File

@ -1,4 +1,4 @@
package io.bkbn.kompendium.models.meta
package io.bkbn.kompendium.core.metadata
data class RequestInfo<TReq>(
val description: String,

View File

@ -1,4 +1,4 @@
package io.bkbn.kompendium.models.meta
package io.bkbn.kompendium.core.metadata
import io.ktor.http.HttpStatusCode

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.core.metadata
import io.bkbn.kompendium.oas.schema.ComponentSchema
typealias SchemaMap = Map<String, ComponentSchema>

View File

@ -0,0 +1,6 @@
package io.bkbn.kompendium.core.metadata
import kotlin.reflect.KTypeParameter
import kotlin.reflect.KTypeProjection
typealias TypeMap = Map<KTypeParameter, KTypeProjection>

View File

@ -0,0 +1,17 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,17 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,17 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
import io.bkbn.kompendium.core.metadata.ResponseInfo
data class HeadInfo<TParam>(
override val responseInfo: ResponseInfo<Unit>,
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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, Unit>

View File

@ -0,0 +1,24 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
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>
val parameterExamples: Set<ParameterExample>
get() = emptySet()
val operationId: String?
get() = null
}

View File

@ -0,0 +1,17 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
import io.bkbn.kompendium.core.metadata.ResponseInfo
data class OptionsInfo<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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,19 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
import io.bkbn.kompendium.core.metadata.RequestInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
data class PatchInfo<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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,19 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,19 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
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: Set<ParameterExample> = emptySet(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,3 @@
package io.bkbn.kompendium.core.parser
object DefaultMethodParser : IMethodParser

View File

@ -0,0 +1,252 @@
package io.bkbn.kompendium.core.parser
import io.bkbn.kompendium.annotations.Param
import io.bkbn.kompendium.core.Kompendium
import io.bkbn.kompendium.core.Kontent
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ParameterExample
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 io.ktor.routing.Route
import kotlin.reflect.KClass
import kotlin.reflect.KParameter
import kotlin.reflect.KProperty
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
import java.util.Locale
import java.util.UUID
interface IMethodParser {
/**
* Generates the OpenAPI Path spec from provided metadata
* @param info implementation of the [MethodInfo] sealed class
* @param paramType Type of `TParam`
* @param requestType Type of `TReq` if required
* @param responseType Type of `TResp`
* @return object representing the OpenAPI Path spec.
*/
fun parseMethodInfo(
info: MethodInfo<*, *>,
paramType: KType,
requestType: KType,
responseType: KType,
feature: Kompendium
) = PathOperation(
summary = info.summary,
description = info.description,
operationId = info.operationId,
tags = info.tags,
deprecated = info.deprecated,
parameters = paramType.toParameterSpec(info, feature),
responses = parseResponse(responseType, info.responseInfo, feature).plus(parseExceptions(info.canThrow, feature)),
requestBody = when (info) {
is PutInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo, feature)
is PostInfo<*, *, *> -> requestType.toRequestSpec(info.requestInfo, feature)
else -> null
},
security = if (info.securitySchemes.isNotEmpty()) listOf(
// TODO support scopes
info.securitySchemes.associateWith { listOf() }
) else null
)
fun parseResponse(
responseType: KType,
responseInfo: ResponseInfo<*>?,
feature: Kompendium
): Map<Int, Response> = responseType.toResponseSpec(responseInfo, feature)?.let { mapOf(it) }.orEmpty()
fun parseExceptions(
exceptionInfo: Set<ExceptionInfo<*>>,
feature: Kompendium,
): Map<Int, Response> = exceptionInfo.associate { info ->
feature.config.cache = Kontent.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)
}
/**
* Converts a [KType] to an [Request]
* @receiver [KType] to convert
* @param requestInfo request metadata
* @return Will return a generated [Request] if requestInfo is not null
*/
fun KType.toRequestSpec(requestInfo: RequestInfo<*>?, feature: Kompendium): Request? =
when (requestInfo) {
null -> null
else -> {
Request(
description = requestInfo.description,
content = feature.resolveContent(this, requestInfo.mediaTypes, requestInfo.examples as Map<String, Any>)
?: mapOf(),
required = requestInfo.required
)
}
}
/**
* 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
*/
fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?, feature: Kompendium): Pair<Int, Response>? =
when (responseInfo) {
null -> null
else -> {
val specResponse = Response(
description = responseInfo.description,
content = feature.resolveContent(this, responseInfo.mediaTypes, responseInfo.examples as Map<String, Any>)
)
Pair(responseInfo.status.value, specResponse)
}
}
/**
* Generates MediaTypes along with any examples provided
* @param type [KType] Type of the object
* @param mediaTypes list of acceptable http media types
* @param examples Mapping of named examples of valid bodies.
* @return Named mapping of media types.
*/
fun Kompendium.resolveContent(
type: KType,
mediaTypes: List<String>,
examples: Map<String, Any>
): Map<String, MediaType>? {
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.getSimpleSlug() }
.map { config.cache[it] ?: error("$it not available") }
AnyOfSchema(refs)
} else {
val ref = type.getSimpleSlug()
config.cache[ref] ?: error("$ref not available")
}
MediaType(
schema = schema,
examples = examples.mapValues { (_, v) -> MediaType.Example(v) }.ifEmpty { null }
)
}
} else null
}
/**
* Parses a type for all parameter information. All fields in the receiver
* 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
*/
fun KType.toParameterSpec(info: MethodInfo<*, *>, feature: Kompendium): List<Parameter> {
val clazz = classifier as KClass<*>
return clazz.memberProperties
.filter { prop -> prop.hasAnnotation<Param>() }
.map { prop -> prop.toParameter(info, this, clazz, feature) }
}
fun KProperty<*>.toParameter(
info: MethodInfo<*, *>,
parentType: KType,
parentClazz: KClass<*>,
feature: Kompendium
): Parameter {
val wrapperSchema = feature.config.cache[parentType.getSimpleSlug()]!! as ObjectSchema
val anny = this.findAnnotation<Param>()
?: error("Field $name is not annotated with KompendiumParam")
val schema = wrapperSchema.properties[name]
?: error("Could not find component type for $this")
val defaultValue = getDefaultParameterValue(parentClazz, this)
return Parameter(
name = name,
`in` = anny.type.name.lowercase(Locale.getDefault()),
schema = schema.addDefault(defaultValue),
description = schema.description,
required = !returnType.isMarkedNullable && defaultValue == null,
examples = info.parameterExamples.mapToSpec(name)
)
}
fun Set<ParameterExample>.mapToSpec(parameterName: String): Map<String, Parameter.Example>? {
val filtered = filter { it.parameterName == parameterName }
return if (filtered.isEmpty()) {
null
} else {
filtered.associate { it.exampleName to Parameter.Example(it.exampleValue) }
}
}
/**
* Absolutely disgusting reflection to determine if a default value is available for a given property.
* @param clazz to which the property belongs
* @param prop the property in question
* @return The default value if found
*/
fun getDefaultParameterValue(clazz: KClass<*>, prop: KProperty<*>): Any? {
val constructor = clazz.primaryConstructor
val parameterInQuestion = constructor
?.parameters
?.find { it.name == prop.name }
?: error("could not find parameter ${prop.name}")
if (!parameterInQuestion.isOptional) {
return null
}
val values = constructor
.parameters
.filterNot { it.isOptional }
.associateWith { defaultValueInjector(it) }
val instance = constructor.callBy(values)
val methods = clazz.java.methods
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)
}
/**
* Allows the reflection invoker to populate a parameter map with values in order to sus out any default parameters.
* @param param Parameter to provide value for
* @return value of the proper type to match param
* @throws [IllegalStateException] if parameter type is not one of the basic types supported below.
*/
fun defaultValueInjector(param: KParameter): Any = when (param.type.classifier) {
String::class -> "test"
Boolean::class -> false
Int::class -> 1
Long::class -> 2
Double::class -> 1.0
Float::class -> 1.0
UUID::class -> UUID.randomUUID()
else -> error("Unsupported Type")
}
/**
* 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("/\\(.+\\)"), "")
}

View File

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

View File

@ -1,4 +1,4 @@
package io.bkbn.kompendium.util
package io.bkbn.kompendium.core.util
import java.lang.reflect.ParameterizedType
import kotlin.reflect.KClass
@ -7,38 +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() }
/**
* Simple extension function that will take a [Pair] and place it (if absent) into a [MutableMap].
*
* @receiver [MutableMap]
* @param pair to add to map
*/
fun <K, V> MutableMap<K, V>.putPairIfAbsent(pair: Pair<K, V>) = putIfAbsent(pair.first, pair.second)
/**
* Simple extension function that will convert a list with two items into a [Pair]
* @receiver [List]
* @return [Pair]
* @throws [IllegalArgumentException] when the list size is not exactly two
*/
fun <T> List<T>.toPair(): Pair<T, T> {
if (this.size != 2) {
throw IllegalArgumentException("List is not of length 2!")
}
return Pair(this[0], this[1])
}
/**
* 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 {
@ -53,6 +33,11 @@ object Helpers {
else -> simpleName ?: error("Could not determine simple name for $this")
}
fun KType.getSimpleSlug(): String = when {
this.arguments.isNotEmpty() -> genericNameAdapter(this, classifier as KClass<*>)
else -> (classifier as KClass<*>).simpleName ?: error("Could not determine simple name for $this")
}
fun KType.getReferenceSlug(): String = when {
arguments.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, classifier as KClass<*>)}"
else -> "$COMPONENT_SLUG/${(classifier as KClass<*>).simpleName}"
@ -85,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()
}
}
}

View File

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

View File

@ -1,95 +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(),
) {
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()
) : MethodInfo<TParam, TResp>(
summary = summary,
description = description,
tags = tags,
deprecated = deprecated,
securitySchemes = securitySchemes,
canThrow = canThrow,
responseInfo = responseInfo,
parameterExamples = parameterExamples
)
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()
) : MethodInfo<TParam, TResp>(
summary = summary,
description = description,
tags = tags,
deprecated = deprecated,
securitySchemes = securitySchemes,
canThrow = canThrow,
responseInfo = responseInfo,
parameterExamples = parameterExamples
)
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()
) : MethodInfo<TParam, TResp>(
summary = summary,
description = description,
tags = tags,
deprecated = deprecated,
securitySchemes = securitySchemes,
canThrow = canThrow,
parameterExamples = parameterExamples
)
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()
) : MethodInfo<TParam, TResp>(
summary = summary,
description = description,
tags = tags,
deprecated = deprecated,
securitySchemes = securitySchemes,
canThrow = canThrow,
parameterExamples = parameterExamples
)
}

View File

@ -1,5 +0,0 @@
package io.bkbn.kompendium.models.meta
import io.bkbn.kompendium.models.oas.OpenApiSpecComponentSchema
typealias SchemaMap = Map<String, OpenApiSpecComponentSchema>

View File

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

View File

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

View File

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

View File

@ -1,8 +0,0 @@
package io.bkbn.kompendium.models.oas
import java.net.URI
data class OpenApiSpecExternalDocumentation(
val url: URI,
val description: String?
)

View File

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

View File

@ -1,9 +0,0 @@
package io.bkbn.kompendium.models.oas
import java.net.URI
data class OpenApiSpecInfoContact(
var name: String,
var url: URI? = null,
var email: String? = null // TODO Enforce email?
)

View File

@ -1,8 +0,0 @@
package io.bkbn.kompendium.models.oas
import java.net.URI
data class OpenApiSpecInfoLicense(
var name: String,
var url: URI? = null
)

View File

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

View File

@ -1,8 +0,0 @@
package io.bkbn.kompendium.models.oas
data class OpenApiSpecMediaType<T>(
val schema: OpenApiSpecReferenceObject,
val examples: Map<String, ExampleWrapper<T>>? = null
)
data class ExampleWrapper<T>(val value: T)

View File

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

Some files were not shown because too many files have changed in this diff Show More