From bba9cbd0e42373bfd42ebf89469cd373914de013 Mon Sep 17 00:00:00 2001 From: Chris Searle Date: Wed, 22 Nov 2023 11:13:07 +0100 Subject: [PATCH] Add OpenAPI/Swagger --- README.md | 431 +----------------- build.gradle.kts | 13 + gradle/libs.versions.toml | 3 + .../kotlin/no/java/partner/Application.kt | 18 + .../no/java/partner/plugins/ListRouting.kt | 81 +++- .../kotlin/no/java/partner/plugins/OpenApi.kt | 52 +++ .../no/java/partner/plugins/PartnerRouting.kt | 13 + .../no/java/partner/plugins/Serialization.kt | 2 + .../plugins/openapi/CommonResponses.kt | 58 +++ .../partner/plugins/openapi/ListRoutingDoc.kt | 162 +++++++ .../plugins/openapi/OpenApiExtensions.kt | 15 + .../plugins/openapi/PartnerRoutingDoc.kt | 119 +++++ .../no/java/partner/TestApplicationBuilder.kt | 2 + 13 files changed, 532 insertions(+), 437 deletions(-) create mode 100644 src/main/kotlin/no/java/partner/plugins/OpenApi.kt create mode 100644 src/main/kotlin/no/java/partner/plugins/openapi/CommonResponses.kt create mode 100644 src/main/kotlin/no/java/partner/plugins/openapi/ListRoutingDoc.kt create mode 100644 src/main/kotlin/no/java/partner/plugins/openapi/OpenApiExtensions.kt create mode 100644 src/main/kotlin/no/java/partner/plugins/openapi/PartnerRoutingDoc.kt diff --git a/README.md b/README.md index fdee3cb..3bdeaed 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,18 @@ ## Models * Partner - * Name of Partner + * Name of Partner * Contacts - * Name of Contact - * E-mail of Contact - * Tlf/Mob for Contact - * Partner - * Lists + * Name of Contact + * E-mail of Contact + * Tlf/Mob for Contact + * Partner + * Lists * Lists - * Name - * Contacts + * Name + * Contacts ### Joins @@ -37,7 +37,8 @@ listOfPartners.mergeFold { p1, p2 -> `docker compose up -d` -This will start a local postgres instance in docker exposing it on port 5555. User test, password test and db name partner. +This will start a local postgres instance in docker exposing it on port 5555. User test, password test and db name +partner. To use this db - pass the following environment variable: @@ -45,413 +46,17 @@ To use this db - pass the following environment variable: ## API -``` -/partner - - GET - list partners - - POST- create partner -/partner/{PID} - - GET - partner with contacts -/partner/{PID}/contact - - POST - create contact -/list - - GET - list mailing lists - - POST - create mailing list -/list/{LID} - - GET - get mailing list with contacts -/list/{LID}/contact/{CID} - - POST - subscribe contact to a list (create the assocication - sets subscribe true) -/list/{LID}/contact/{CID}/subscribe - - PATCH - set subscribe flag true on a subscription -/list/{LID}/contact/{CID}/unsubscribe - - PATCH - set subscribe false true on a subscription -``` - -## Examples - -Given the following data: - -### Partner - -| ID | Domain | Name | -|----|---------------|-----------| -| 1 | partner.1.tld | Partner 1 | -| 2 | partner.2.tld | Partner 2 | -| 3 | partner.3.tld | Partner 3 | - -### Contact - -| ID | Name | E-mail | Telephone | Source | Partner | -|----|-----------|---------------------|-----------|----------|---------| -| 1 | Contact 1 | contact1@domain.tld | 12345678 | Source 1 | 1 | -| 2 | Contact 2 | contact2@domain.tld | 22345678 | Source 2 | 1 | -| 3 | Contact 3 | contact3@domain.tld | 32345678 | Source 3 | 1 | -| 4 | Contact 3 | contact4@domain.tld | 42345678 | null | 1 | -| 5 | Contact 3 | contact5@domain.tld | 52345678 | null | 2 | - -### List - -| ID | Name | -|----|--------| -| 1 | List 1 | -| 2 | List 2 | - -### Contact List - -| Contact Id | List Id | Subscribed | -|------------|---------|------------| -| 1 | 1 | true | -| 1 | 2 | true | -| 1 | 3 | false | -| 3 | 1 | true | - -Then: - -### http://localhost:8080/partner - -Fetches list of partners - -```json -[ - { - "id": 1, - "name": "Partner 1", - "domainName": [ - "partner.1.tld" - ] - }, - { - "id": 2, - "name": "Partner 2", - "domainName": [ - "partner.2.tld" - ] - }, - { - "id": 3, - "name": "Partner 3", - "domainName": [ - "partner.3.tld" - ] - } -] -``` - -### http://localhost:8080/partner/X - -Fetches partner with basic contact info and for each contact basic list info for subscribed lists - -e.g X=1 - -```json -{ - "id": 1, - "name": "Partner 1", - "domainName": [ - "partner.1.tld" - ], - "contacts": [ - { - "id": 4, - "name": "Contact 4", - "email": "contact4@domain.tld", - "telephone": "42345678", - "source": null, - "lists": [] - }, - { - "id": 2, - "name": "Contact 2", - "email": "contact2@domain.tld", - "telephone": "22345678", - "source": "Source 2", - "lists": [] - }, - { - "id": 3, - "name": "Contact 3", - "email": "contact3@domain.tld", - "telephone": "32345678", - "source": "Source 3", - "lists": [ - { - "id": 1, - "name": "List 1" - } - ] - }, - { - "id": 1, - "name": "Contact 1", - "email": "contact1@domain.tld", - "telephone": "12345678", - "source": "Source 1", - "lists": [ - { - "id": 1, - "name": "List 1" - }, - { - "id": 2, - "name": "List 2" - } - ] - } - ] -} -``` - -#### If partner not found: - -404 Not found - -```json -{ - "message": "Partner not found" -} -``` - -### POST http://localhost:8080/partner - -Creates a partner - -Body: - -```json -{ - "name": "Test Partner", - "domainName": ["test1.domain.tld", "test2.domain.tld"] -} -``` - -Response: - -```json -{ - "id": 4, - "name": "Test Partner", - "domainName": [ - "test1.domain.tld", - "test2.domain.tld" - ] -} -``` +See swagger on http://localhost:8080/swagger-ui -### POST http://localhost:8080/partner/X/contact/ +Provide a current JWT via the authorize function. -Creates a contact for a partner - returns the updated partner +## Auth -e.g. X=4 +The app expects two environment variables - GITHUB_ID and GITHUB_SECRET -Body: +This connects `/login` to login with GitHub. -```json -{ - "name": "Test Contact", - "email": "test@test.domain.tld", - "source": "Test Source" -} -``` - -Response: - -```json -{ - "id": 4, - "name": "Test Partner", - "domainName": [ - "test1.domain.tld", - "test2.domain.tld" - ] - "contacts": [ - { - "id": 6, - "name": "Test Contact", - "email": "test@test.domain.tld", - "telephone": null, - "source": "Test Source", - "lists": [] - } - ] -} -``` +On return the application will exchange this for a local JWT. -### `http://localhost:8080/list` - -Fetches a list of lists - -```json -[ - { - "id": 1, - "name": "List 1" - }, - { - "id": 2, - "name": "List 2" - }, - { - "id": 3, - "name": "List 3" - } -] -``` - -### POST http://localhost:8080/list - -Creates a list - -Body: - -```json -{ - "name": "Test List" -} -``` - -Response: - -```json -{ - "id": 4, - "name": "Test List" -} -``` - -### http://localhost:8080/list/X - -Fetches a list. - -e.g X=1 - -```json -{ - "id": 1, - "name": "List 1", - "contacts": [ - { - "id": 3, - "name": "Contact 3", - "email": "contact3@domain.tld", - "telephone": "32345678", - "source": "Source 3" - }, - { - "id": 1, - "name": "Contact 1", - "email": "contact1@domain.tld", - "telephone": "12345678", - "source": "Source 1" - } - ], - "unsubscribed": [] -} -``` - -e.g. X=3 - -```json -{ - "id": 3, - "name": "List 3", - "contacts": [], - "unsubscribed": [ - { - "id": 1, - "name": "Contact 1", - "email": "contact1@domain.tld", - "telephone": "12345678", - "source": "Source 1" - } - ] -} -``` - -#### If list not found: - -404 Not found - -```json -{ - "message": "List not found" -} -``` - -### POST http://localhost:8080/list/X/contact/Y - -Subscribes a contact to a list - -e.g. X=2 and Y=2 (list 2 has contact 1 already) - -```json -{ - "id": 2, - "name": "List 2", - "contacts": [ - { - "id": 2, - "name": "Contact 2", - "email": "contact2@domain.tld", - "telephone": "22345678", - "source": "Source 2" - }, - { - "id": 1, - "name": "Contact 1", - "email": "contact1@domain.tld", - "telephone": "12345678", - "source": "Source 1" - } - ], - "unsubscribed": [] -} -``` - -### PATCH http://localhost:8080/list/X/contact/Y/subscribe - -e.g. X=3 and Y=1 - -```json -{ - "id": 3, - "name": "List 3", - "contacts": [ - { - "id": 1, - "name": "Contact 1", - "email": "contact1@domain.tld", - "telephone": "12345678", - "source": "Source 1" - } - ], - "unsubscribed": [] -} -``` - -### PATCH http://localhost:8080/list/X/contact/Y/unsubscribe - -e.g. X=1 and Y=3 - -```json -{ - "id": 1, - "name": "List 1", - "contacts": [ - { - "id": 1, - "name": "Contact 1", - "email": "contact1@domain.tld", - "telephone": "12345678", - "source": "Source 1" - } - ], - "unsubscribed": [ - { - "id": 3, - "name": "Contact 3", - "email": "contact3@domain.tld", - "telephone": "32345678", - "source": "Source 3" - } - ] -} -``` \ No newline at end of file +When running locally - the static page served on / will display the current JWT if found. +This makes testing locally with swagger simpler. diff --git a/build.gradle.kts b/build.gradle.kts index ac11181..9a24aef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.kotlin) alias(libs.plugins.ktor) @@ -41,6 +43,7 @@ dependencies { implementation(libs.arrow.core) + implementation(libs.kompendium.core) implementation(libs.kotliquery) implementation(libs.flyway) implementation(libs.flyway.postgres) @@ -90,3 +93,13 @@ tasks.jacocoTestReport { } dependsOn(tasks.test) } + +tasks.withType() { + 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) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f0d7c5..52f87f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } diff --git a/src/main/kotlin/no/java/partner/Application.kt b/src/main/kotlin/no/java/partner/Application.kt index 934e4ac..d761485 100644 --- a/src/main/kotlin/no/java/partner/Application.kt +++ b/src/main/kotlin/no/java/partner/Application.kt @@ -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): 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) @@ -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, ) diff --git a/src/main/kotlin/no/java/partner/plugins/ListRouting.kt b/src/main/kotlin/no/java/partner/plugins/ListRouting.kt index acc5c74..3e4701f 100644 --- a/src/main/kotlin/no/java/partner/plugins/ListRouting.kt +++ b/src/main/kotlin/no/java/partner/plugins/ListRouting.kt @@ -1,11 +1,14 @@ 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 @@ -13,12 +16,18 @@ 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() }) } @@ -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() + } + } + } +} diff --git a/src/main/kotlin/no/java/partner/plugins/OpenApi.kt b/src/main/kotlin/no/java/partner/plugins/OpenApi.kt new file mode 100644 index 0000000..bbafcae --- /dev/null +++ b/src/main/kotlin/no/java/partner/plugins/OpenApi.kt @@ -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() to TypeDefinition(type = "string", format = "date-time"), + typeOf() to TypeDefinition(type = "string", format = "date"), + typeOf() to TypeDefinition(type = "string", format = "date-time"), + ) + } + + routing { + swagger(pageTitle = "javaBin Partner API") + } +} diff --git a/src/main/kotlin/no/java/partner/plugins/PartnerRouting.kt b/src/main/kotlin/no/java/partner/plugins/PartnerRouting.kt index 128b0a8..a13ca14 100644 --- a/src/main/kotlin/no/java/partner/plugins/PartnerRouting.kt +++ b/src/main/kotlin/no/java/partner/plugins/PartnerRouting.kt @@ -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 @@ -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() }) } @@ -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() diff --git a/src/main/kotlin/no/java/partner/plugins/Serialization.kt b/src/main/kotlin/no/java/partner/plugins/Serialization.kt index ffffc51..2e436c6 100644 --- a/src/main/kotlin/no/java/partner/plugins/Serialization.kt +++ b/src/main/kotlin/no/java/partner/plugins/Serialization.kt @@ -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 @@ -15,6 +16,7 @@ fun Application.configureSerialization() { enable(SerializationFeature.INDENT_OUTPUT) registerModule(JavaTimeModule()) disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + setSerializationInclusion(JsonInclude.Include.NON_NULL) } } diff --git a/src/main/kotlin/no/java/partner/plugins/openapi/CommonResponses.kt b/src/main/kotlin/no/java/partner/plugins/openapi/CommonResponses.kt new file mode 100644 index 0000000..5c29c90 --- /dev/null +++ b/src/main/kotlin/no/java/partner/plugins/openapi/CommonResponses.kt @@ -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(), + "ID Parameter missing", +) + +private data object NotFoundResponse : StandardApiResponseInfo( + HttpStatusCode.NotFound, + "Not Found - could not find something", + typeOf(), + "Partner Not Found", + "List Not Found", + "Contact Not Found", +) + +private data object UnauthorizedResponse : StandardApiResponseInfo( + HttpStatusCode.Unauthorized, + "Unauthorized - not logged in", + typeOf(), + "Missing Principal", + "Not an admin", +) + +private data object InternalServerErrorResponse : StandardApiResponseInfo( + HttpStatusCode.InternalServerError, + "Internal Server Error - something went wrong", + typeOf(), + "Something went wrong", +) + +val BadRequestResponseInfo = BadRequestResponse.build() +val NotFoundResponseInfo = NotFoundResponse.build() +val UnauthorizedResponseInfo = UnauthorizedResponse.build() +val InternalServerErrorResponseInfo = InternalServerErrorResponse.build() diff --git a/src/main/kotlin/no/java/partner/plugins/openapi/ListRoutingDoc.kt b/src/main/kotlin/no/java/partner/plugins/openapi/ListRoutingDoc.kt new file mode 100644 index 0000000..aea1174 --- /dev/null +++ b/src/main/kotlin/no/java/partner/plugins/openapi/ListRoutingDoc.kt @@ -0,0 +1,162 @@ +package no.java.partner.plugins.openapi + +import io.bkbn.kompendium.core.metadata.GetInfo +import io.bkbn.kompendium.core.metadata.PatchInfo +import io.bkbn.kompendium.core.metadata.PostInfo +import io.ktor.http.HttpStatusCode +import no.java.partner.model.web.BasicContact +import no.java.partner.model.web.BasicInfoList +import no.java.partner.model.web.CreateInfoList +import no.java.partner.model.web.InfoListWithContacts +import kotlin.reflect.typeOf + +@Suppress("Duplicates") +object ListRoutingDoc { + private val exampleList = BasicInfoList(id = 1L, name = "Info") + private val exampleCreateList = CreateInfoList(name = "Info") + private val exampleListWithContacts = InfoListWithContacts( + id = 1L, + name = "Info", + contacts = listOf( + BasicContact(id = 1L, name = "Duke", email = "duke@java.no", telephone = "12345678", source = "Example"), + ), + unsubscribed = emptyList(), + ) + private val exampleListWithContactsUnsubscribed = InfoListWithContacts( + id = 1L, + name = "Info", + contacts = emptyList(), + unsubscribed = listOf( + BasicContact(id = 1L, name = "Duke", email = "duke@java.no", telephone = "12345678", source = "Example"), + ), + ) + + val listList = + GetInfo.builder { + summary("List List") + description("Fetch all lists") + response { + responseType(typeOf>()) + responseCode(HttpStatusCode.OK) + description("List of lists") + examples(listOf(exampleList).toExample()) + } + canRespond(listOf(UnauthorizedResponseInfo, InternalServerErrorResponseInfo)) + tags("Lists") + } + + val createList = + PostInfo.builder { + summary("Create List") + description("Create a List") + request { + requestType(typeOf()) + description("List to create") + examples(exampleCreateList.toExample()) + } + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Newly created list") + examples(exampleList.toExample()) + } + canRespond( + listOf( + UnauthorizedResponseInfo, + BadRequestResponseInfo, + NotFoundResponseInfo, + InternalServerErrorResponseInfo, + ), + ) + tags("Lists") + } + + val listById = + GetInfo.builder { + summary("List By ID") + description("Fetch a single list") + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("List") + examples(exampleListWithContacts.toExample()) + } + canRespond(listOf(UnauthorizedResponseInfo, NotFoundResponseInfo, InternalServerErrorResponseInfo)) + tags("Lists") + } + + val createSubscription = + PostInfo.builder { + summary("Create subscription") + description("Subscribe to a list") + parameters( + "id".pathParam(), + "contact".pathParam(), + ) + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Updated list") + examples(exampleListWithContacts.toExample()) + } + canRespond( + listOf( + UnauthorizedResponseInfo, + BadRequestResponseInfo, + NotFoundResponseInfo, + InternalServerErrorResponseInfo, + ), + ) + tags("Lists") + } + + val subscribe = + PatchInfo.builder { + summary("Subscribe") + description("Update existing subscription - set subscribed") + parameters( + "id".pathParam(), + "contact".pathParam(), + ) + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Updated list") + examples(exampleListWithContacts.toExample()) + } + canRespond( + listOf( + UnauthorizedResponseInfo, + BadRequestResponseInfo, + NotFoundResponseInfo, + InternalServerErrorResponseInfo, + ), + ) + tags("Lists") + } + + val unsubscribe = + PatchInfo.builder { + summary("Unsubscribe") + description("Update existing subscription - set unsubscribed") + parameters( + "id".pathParam(), + "contact".pathParam(), + ) + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Updated list") + examples(exampleListWithContactsUnsubscribed.toExample()) + } + canRespond( + listOf( + UnauthorizedResponseInfo, + BadRequestResponseInfo, + NotFoundResponseInfo, + InternalServerErrorResponseInfo, + ), + ) + tags("Lists") + } +} diff --git a/src/main/kotlin/no/java/partner/plugins/openapi/OpenApiExtensions.kt b/src/main/kotlin/no/java/partner/plugins/openapi/OpenApiExtensions.kt new file mode 100644 index 0000000..f05427c --- /dev/null +++ b/src/main/kotlin/no/java/partner/plugins/openapi/OpenApiExtensions.kt @@ -0,0 +1,15 @@ +package no.java.partner.plugins.openapi + +import io.bkbn.kompendium.json.schema.definition.TypeDefinition +import io.bkbn.kompendium.oas.payload.Parameter + +fun String.pathParam() = Parameter( + name = this, + `in` = Parameter.Location.path, + schema = TypeDefinition.STRING, +) + +fun Any.toExample() = Pair( + "example", + this, +) diff --git a/src/main/kotlin/no/java/partner/plugins/openapi/PartnerRoutingDoc.kt b/src/main/kotlin/no/java/partner/plugins/openapi/PartnerRoutingDoc.kt new file mode 100644 index 0000000..4aecc7c --- /dev/null +++ b/src/main/kotlin/no/java/partner/plugins/openapi/PartnerRoutingDoc.kt @@ -0,0 +1,119 @@ +package no.java.partner.plugins.openapi + +import io.bkbn.kompendium.core.metadata.GetInfo +import io.bkbn.kompendium.core.metadata.PostInfo +import io.ktor.http.HttpStatusCode +import no.java.partner.model.web.BasicInfoList +import no.java.partner.model.web.BasicPartner +import no.java.partner.model.web.ContactWithLists +import no.java.partner.model.web.CreateContact +import no.java.partner.model.web.CreatePartner +import no.java.partner.model.web.PartnerWithContacts +import kotlin.reflect.typeOf + +@Suppress("Duplicates") +object PartnerRoutingDoc { + private val examplePartner = BasicPartner(id = 1L, name = "javaBin", domainName = listOf("java.no")) + private val exampleCreatePartner = CreatePartner(name = "javaBin", domainName = listOf("java.no")) + private val examplePartnerWithContacts = PartnerWithContacts( + id = 1L, + name = "javaBin", + domainName = listOf("java.no"), + contacts = listOf( + ContactWithLists( + id = 1L, + name = "Duke", + email = "duke@java.no", + telephone = "12345678", + source = "Example", + lists = listOf( + BasicInfoList(id = 1L, name = "Info"), + ), + ), + ), + ) + private val exampleCreateContact = + CreateContact(name = "Duke", email = "duke@java.no", telephone = "12345678", source = "Example") + + val partnerList = + GetInfo.builder { + summary("Partner List") + description("Fetch all partners") + response { + responseType(typeOf>()) + responseCode(HttpStatusCode.OK) + description("List of partners") + examples(listOf(examplePartner).toExample()) + } + canRespond(listOf(UnauthorizedResponseInfo, InternalServerErrorResponseInfo)) + tags("Partners") + } + + val createPartner = + PostInfo.builder { + summary("Create Partner") + description("Create a partner") + request { + requestType(typeOf()) + description("Partner to create") + examples(exampleCreatePartner.toExample()) + } + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Newly created partner") + examples(examplePartner.toExample()) + } + canRespond( + listOf( + UnauthorizedResponseInfo, + BadRequestResponseInfo, + NotFoundResponseInfo, + InternalServerErrorResponseInfo, + ), + ) + tags("Partners") + } + + val partnerById = + GetInfo.builder { + summary("Partner By ID") + description("Fetch a single partner") + parameters("id".pathParam()) + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Partner") + examples(listOf(examplePartnerWithContacts).toExample()) + } + canRespond(listOf(UnauthorizedResponseInfo, NotFoundResponseInfo, InternalServerErrorResponseInfo)) + tags("Partners") + } + + val createContact = + PostInfo.builder { + summary("Create Contact") + description("Create a contact for a partner") + request { + requestType(typeOf()) + description("Contact to create") + parameters("id".pathParam()) + examples(exampleCreateContact.toExample()) + } + response { + responseType(typeOf()) + responseCode(HttpStatusCode.OK) + description("Updated partner") + examples(examplePartnerWithContacts.toExample()) + } + canRespond( + listOf( + UnauthorizedResponseInfo, + BadRequestResponseInfo, + NotFoundResponseInfo, + InternalServerErrorResponseInfo, + ), + ) + tags("Partners") + } +} diff --git a/src/test/kotlin/no/java/partner/TestApplicationBuilder.kt b/src/test/kotlin/no/java/partner/TestApplicationBuilder.kt index 409fd99..2033c9e 100644 --- a/src/test/kotlin/no/java/partner/TestApplicationBuilder.kt +++ b/src/test/kotlin/no/java/partner/TestApplicationBuilder.kt @@ -10,6 +10,7 @@ import io.ktor.server.testing.ApplicationTestBuilder import io.mockk.mockk import no.java.partner.plugins.UserInfo import no.java.partner.plugins.buildToken +import no.java.partner.plugins.configureOpenApi import no.java.partner.plugins.configureRouting import no.java.partner.plugins.configureSecurity import no.java.partner.plugins.configureSerialization @@ -52,6 +53,7 @@ fun ApplicationTestBuilder.app(config: Application.() -> Unit) { configureSerialization() configureRouting() configureSecurity(mockk()) + configureOpenApi("Test", 8080) config() }