Skip to content

Commit

Permalink
Add OpenAPI/Swagger
Browse files Browse the repository at this point in the history
  • Loading branch information
chrissearle committed Nov 22, 2023
1 parent fcb3a04 commit bba9cbd
Show file tree
Hide file tree
Showing 13 changed files with 532 additions and 437 deletions.
431 changes: 18 additions & 413 deletions README.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.util.Properties

plugins {
alias(libs.plugins.kotlin)
alias(libs.plugins.ktor)
Expand Down Expand Up @@ -41,6 +43,7 @@ dependencies {

implementation(libs.arrow.core)

implementation(libs.kompendium.core)
implementation(libs.kotliquery)
implementation(libs.flyway)
implementation(libs.flyway.postgres)
Expand Down Expand Up @@ -90,3 +93,13 @@ tasks.jacocoTestReport {
}
dependsOn(tasks.test)
}

tasks.withType<ProcessResources>() {
doLast {
val propertiesFile = rootProject.layout.buildDirectory.dir("resources/main/version.properties").get().asFile
propertiesFile.parentFile.mkdirs()
val properties = Properties()
properties.setProperty("version", rootProject.version.toString())
propertiesFile.writer().use { properties.store(it, null) }
}
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ detekt_version = "1.23.3"
flyway_version = "10.0.1"
hikaricp_version = "5.0.1"
jackson_datatype_jsr310_version = "2.15.3"
kompendium_version = "3.14.4"
kotest_testcontainers_version = "2.0.2"
kotest_version = "5.8.0"
kotlin_logging_version = "3.0.5"
Expand All @@ -27,6 +28,8 @@ hikaricp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikaricp_ve

jackson-datatype-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jackson_datatype_jsr310_version" }

kompendium-core = { group = "io.bkbn", name = "kompendium-core", version.ref = "kompendium_version" }

kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest_version" }
kotest-extensions-testcontainers = { group = "io.kotest.extensions", name = "kotest-extensions-testcontainers", version.ref = "kotest_testcontainers_version" }
kotest-framework-datatest = { group = "io.kotest", name = "kotest-framework-datatest", version.ref = "kotest_version" }
Expand Down
18 changes: 18 additions & 0 deletions src/main/kotlin/no/java/partner/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,28 @@ import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.jackson.jackson
import io.ktor.server.application.Application
import no.java.partner.plugins.configureMonitoring
import no.java.partner.plugins.configureOpenApi
import no.java.partner.plugins.configureRouting
import no.java.partner.plugins.configureSecurity
import no.java.partner.plugins.configureSerialization
import no.java.partner.plugins.configureServices
import no.java.partner.plugins.dataSource
import java.util.Properties

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

object VersionConfig {
private val versionProps by lazy {
Properties().also {
it.load(this.javaClass.getResourceAsStream("/version.properties"))
}
}

val version by lazy {
versionProps.getProperty("version") ?: "no version"
}
}

fun Application.httpClient() = HttpClient(OkHttp) {
install(Logging)

Expand All @@ -30,6 +44,10 @@ fun Application.module() {
configureSerialization()
configureMonitoring()
configureRouting()
configureOpenApi(
version = VersionConfig.version,
port = environment.config.property("ktor.deployment.port").getString().toInt(),
)
configureSecurity(
httpClient,
)
Expand Down
81 changes: 57 additions & 24 deletions src/main/kotlin/no/java/partner/plugins/ListRouting.kt
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
package no.java.partner.plugins

import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import no.java.partner.model.web.toBasicInfoList
import no.java.partner.model.web.toInfoListWithContacts
import no.java.partner.plugins.openapi.ListRoutingDoc
import no.java.partner.service.ListService

fun Application.configureListRouting(service: ListService) {
routing {
authenticate("auth-jwt") {
route("/list") {
install(NotarizedRoute()) {
get = ListRoutingDoc.listList
post = ListRoutingDoc.createList
}

get {
call.respond(HttpStatusCode.OK, service.allLists().map { it.toBasicInfoList() })
}
Expand All @@ -28,38 +37,62 @@ fun Application.configureListRouting(service: ListService) {
}

route("/{id}") {
install(NotarizedRoute()) {
get = ListRoutingDoc.listById
}

get {
service.listById(call.parameters["id"]?.toLong()).map { it.toInfoListWithContacts() }.respond()
}

route("/contact") {
route("/{contact}") {
post {
service.createSubscription(
listId = call.parameters["id"]?.toLong(),
contactId = call.parameters["contact"]?.toLong(),
).map { it.toInfoListWithContacts() }.respond()
}

patch("/subscribe") {
service.updateSubscription(
listId = call.parameters["id"]?.toLong(),
contactId = call.parameters["contact"]?.toLong(),
subscription = true,
).map { it.toInfoListWithContacts() }.respond()
}

patch("/unsubscribe") {
service.updateSubscription(
listId = call.parameters["id"]?.toLong(),
contactId = call.parameters["contact"]?.toLong(),
subscription = false,
).map { it.toInfoListWithContacts() }.respond()
}
}
contactRoute(service)
}
}
}
}
}
}

private fun Route.contactRoute(service: ListService) {
route("/{contact}") {
install(NotarizedRoute()) {
post = ListRoutingDoc.createSubscription
}

post {
service.createSubscription(
listId = call.parameters["id"]?.toLong(),
contactId = call.parameters["contact"]?.toLong(),
).map { it.toInfoListWithContacts() }.respond()
}

route("/subscribe") {
install(NotarizedRoute()) {
patch = ListRoutingDoc.subscribe
}

patch {
service.updateSubscription(
listId = call.parameters["id"]?.toLong(),
contactId = call.parameters["contact"]?.toLong(),
subscription = true,
).map { it.toInfoListWithContacts() }.respond()
}
}

route("/unsubscribe") {
install(NotarizedRoute()) {
patch = ListRoutingDoc.unsubscribe
}

patch {
service.updateSubscription(
listId = call.parameters["id"]?.toLong(),
contactId = call.parameters["contact"]?.toLong(),
subscription = false,
).map { it.toInfoListWithContacts() }.respond()
}
}
}
}
52 changes: 52 additions & 0 deletions src/main/kotlin/no/java/partner/plugins/OpenApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package no.java.partner.plugins

import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.routes.swagger
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.OpenApiSpec
import io.bkbn.kompendium.oas.component.Components
import io.bkbn.kompendium.oas.info.Info
import io.bkbn.kompendium.oas.security.BearerAuth
import io.bkbn.kompendium.oas.server.Server
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.routing.routing
import java.net.URI
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import kotlin.reflect.typeOf

fun Application.configureOpenApi(version: String, port: Int) {
install(NotarizedApplication()) {
spec = OpenApiSpec(
jsonSchemaDialect = "https://spec.openapis.org/oas/3.1/dialect/base",
info = Info(
"Orbit API",
version = version,
description = "API for managing javaBin partners",
),
servers = mutableListOf(
Server(
url = URI("http://localhost:$port"),
description = "Dev",
),
),
components = Components(
securitySchemes = mutableMapOf(
"auth-jwt" to BearerAuth(),
),
),
)
customTypes =
mapOf(
typeOf<Instant>() to TypeDefinition(type = "string", format = "date-time"),
typeOf<LocalDate>() to TypeDefinition(type = "string", format = "date"),
typeOf<LocalDateTime>() to TypeDefinition(type = "string", format = "date-time"),
)
}

routing {
swagger(pageTitle = "javaBin Partner API")
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/no/java/partner/plugins/PartnerRouting.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package no.java.partner.plugins

import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
Expand All @@ -15,12 +17,18 @@ import no.java.partner.model.web.CreatePartner
import no.java.partner.model.web.toBasicPartner
import no.java.partner.model.web.toNewPartner
import no.java.partner.model.web.toPartnerWithContacts
import no.java.partner.plugins.openapi.PartnerRoutingDoc
import no.java.partner.service.PartnerService

fun Application.configurePartnerRouting(service: PartnerService) {
routing {
authenticate("auth-jwt") {
route("/partner") {
install(NotarizedRoute()) {
get = PartnerRoutingDoc.partnerList
post = PartnerRoutingDoc.createPartner
}

get {
call.respond(HttpStatusCode.OK, service.allPartners().map { it.toBasicPartner() })
}
Expand All @@ -31,6 +39,11 @@ fun Application.configurePartnerRouting(service: PartnerService) {
}

route("/{id}") {
install(NotarizedRoute()) {
get = PartnerRoutingDoc.partnerById
post = PartnerRoutingDoc.createContact
}

get {
service.partnerById(call.parameters["id"]?.toLong()).map { it.toPartnerWithContacts() }
.respond()
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/no/java/partner/plugins/Serialization.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package no.java.partner.plugins

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import io.ktor.serialization.jackson.jackson
Expand All @@ -15,6 +16,7 @@ fun Application.configureSerialization() {
enable(SerializationFeature.INDENT_OUTPUT)
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}

Expand Down
58 changes: 58 additions & 0 deletions src/main/kotlin/no/java/partner/plugins/openapi/CommonResponses.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package no.java.partner.plugins.openapi

import io.bkbn.kompendium.core.metadata.ResponseInfo
import io.ktor.http.HttpStatusCode
import kotlin.reflect.KType
import kotlin.reflect.typeOf

private sealed class StandardApiResponseInfo(
private val statusCode: HttpStatusCode,
private val description: String,
private val responseType: KType,
private vararg val examples: Any,
) {

fun build() = ResponseInfo.builder {
responseCode(statusCode)
description(description)
responseType(responseType)
@Suppress("SpreadOperator")
examples(*(examples.mapIndexed { idx, example -> Pair("example-$idx", example) }.toTypedArray()))
}
}

private data object BadRequestResponse : StandardApiResponseInfo(
HttpStatusCode.BadRequest,
"Bad Request - could not parse request",
typeOf<String>(),
"ID Parameter missing",
)

private data object NotFoundResponse : StandardApiResponseInfo(
HttpStatusCode.NotFound,
"Not Found - could not find something",
typeOf<String>(),
"Partner Not Found",
"List Not Found",
"Contact Not Found",
)

private data object UnauthorizedResponse : StandardApiResponseInfo(
HttpStatusCode.Unauthorized,
"Unauthorized - not logged in",
typeOf<String>(),
"Missing Principal",
"Not an admin",
)

private data object InternalServerErrorResponse : StandardApiResponseInfo(
HttpStatusCode.InternalServerError,
"Internal Server Error - something went wrong",
typeOf<String>(),
"Something went wrong",
)

val BadRequestResponseInfo = BadRequestResponse.build()
val NotFoundResponseInfo = NotFoundResponse.build()
val UnauthorizedResponseInfo = UnauthorizedResponse.build()
val InternalServerErrorResponseInfo = InternalServerErrorResponse.build()
Loading

0 comments on commit bba9cbd

Please sign in to comment.