diff --git a/CHANGELOG.md b/CHANGELOG.md index dc666ae8f..58122cec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### Added +- Fix integration with application `rootPath` which is removed when `NotarizedRoute` resolve path. + ### Changed ### Remove diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt index 7236289b5..428aded6a 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt @@ -18,6 +18,7 @@ import io.ktor.server.application.Hook import io.ktor.server.application.PluginBuilder import io.ktor.server.application.createRouteScopedPlugin import io.ktor.server.routing.Route +import io.ktor.server.routing.application object NotarizedRoute { class Config : SpecConfig { @@ -43,7 +44,6 @@ object NotarizedRoute { name = "NotarizedRoute", createConfiguration = ::Config ) { - // This is required in order to introspect the route path and authentication on(InstallHook) { val route = it as? Route ?: return@on @@ -76,7 +76,17 @@ object NotarizedRoute { spec.paths[fullPath] = path } - fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "") + fun Route.calculateRoutePath() = toString() + .let { + application.environment.rootPath.takeIf { root -> root.isNotEmpty() } + ?.let { root -> + val sanitizedRoute = if (root.startsWith("/")) root else "/$root" + it.replace(sanitizedRoute, "") + } + ?: it + } + .replace(Regex("/\\(.+\\)"), "") + fun Route.collectAuthMethods() = toString() .split("/") .filter { it.contains(Regex("\\(authenticate .*\\)")) } diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt index e3ed83a39..be0136b0c 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt @@ -22,6 +22,7 @@ import io.bkbn.kompendium.core.util.nestedGenericCollection import io.bkbn.kompendium.core.util.nestedGenericMultipleParamsCollection import io.bkbn.kompendium.core.util.nestedGenericResponse import io.bkbn.kompendium.core.util.nestedTypeName +import io.bkbn.kompendium.core.util.nestedUnderRoot import io.bkbn.kompendium.core.util.nonRequiredParam import io.bkbn.kompendium.core.util.nonRequiredParams import io.bkbn.kompendium.core.util.notarizedDelete @@ -44,18 +45,17 @@ import io.bkbn.kompendium.core.util.primitives import io.bkbn.kompendium.core.util.reqRespExamples import io.bkbn.kompendium.core.util.requiredParams import io.bkbn.kompendium.core.util.returnsList +import io.bkbn.kompendium.core.util.rootRoute import io.bkbn.kompendium.core.util.samePathDifferentMethodsAndAuth import io.bkbn.kompendium.core.util.samePathSameMethod import io.bkbn.kompendium.core.util.simpleGenericResponse +import io.bkbn.kompendium.core.util.simplePathParsing import io.bkbn.kompendium.core.util.simpleRecursive import io.bkbn.kompendium.core.util.singleException import io.bkbn.kompendium.core.util.topLevelNullable +import io.bkbn.kompendium.core.util.trailingSlash import io.bkbn.kompendium.core.util.unbackedFieldsResponse import io.bkbn.kompendium.core.util.withOperationId -import io.bkbn.kompendium.core.util.nestedUnderRoot -import io.bkbn.kompendium.core.util.rootRoute -import io.bkbn.kompendium.core.util.simplePathParsing -import io.bkbn.kompendium.core.util.trailingSlash import io.bkbn.kompendium.json.schema.definition.TypeDefinition import io.bkbn.kompendium.json.schema.exception.UnknownSchemaException import io.bkbn.kompendium.oas.component.Components @@ -77,6 +77,7 @@ import io.ktor.server.auth.UserIdPrincipal import io.ktor.server.auth.basic import io.ktor.server.auth.jwt.jwt import io.ktor.server.auth.oauth +import java.net.URI import java.time.Instant import kotlin.reflect.typeOf @@ -275,6 +276,19 @@ class KompendiumTest : DescribeSpec({ } ) { samePathDifferentMethodsAndAuth() } } + it("Can generate paths without application root-path") { + openApiTestAllSerializers( + "T0054__app_with_rootpath.json", + applicationEnvironmentBuilder = { + rootPath = "/example" + }, + specOverrides = { + copy( + servers = servers.map { it.copy(url = URI("${it.url}/example")) }.toMutableList() + ) + } + ) { notarizedGet() } + } } describe("Error Handling") { it("Throws a clear exception when an unidentified type is encountered") { diff --git a/core/src/test/resources/T0054__app_with_rootpath.json b/core/src/test/resources/T0054__app_with_rootpath.json new file mode 100644 index 000000000..dad3532e7 --- /dev/null +++ b/core/src/test/resources/T0054__app_with_rootpath.json @@ -0,0 +1,92 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "Test API", + "version": "1.33.7", + "description": "An amazing, fully-ish 😉 generated API spec", + "termsOfService": "https://example.com", + "contact": { + "name": "Homer Simpson", + "url": "https://gph.is/1NPUDiM", + "email": "chunkylover53@aol.com" + }, + "license": { + "name": "MIT", + "url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE" + } + }, + "servers": [ + { + "url": "https://myawesomeapi.com/example", + "description": "Production instance of my API" + }, + { + "url": "https://staging.myawesomeapi.com/example", + "description": "Where the fun stuff happens" + } + ], + "paths": { + "/test/{a}": { + "get": { + "tags": [], + "summary": "Great Summary!", + "description": "testing more", + "parameters": [], + "responses": { + "200": { + "description": "A Successful Endeavor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResponse" + } + } + } + } + }, + "deprecated": false + }, + "parameters": [ + { + "name": "a", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false + }, + { + "name": "aa", + "in": "query", + "schema": { + "type": "number", + "format": "int32" + }, + "required": true, + "deprecated": false + } + ] + } + }, + "webhooks": {}, + "components": { + "schemas": { + "TestResponse": { + "type": "object", + "properties": { + "c": { + "type": "string" + } + }, + "required": [ + "c" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [] +} diff --git a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt index da774ab5f..30ab58f6a 100644 --- a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt +++ b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt @@ -21,13 +21,14 @@ import io.ktor.serialization.gson.gson import io.ktor.serialization.jackson.jackson import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application +import io.ktor.server.engine.ApplicationEngineEnvironmentBuilder import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.routing.Routing import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication -import kotlin.reflect.KType -import kotlinx.serialization.json.Json import java.io.File +import kotlinx.serialization.json.Json +import kotlin.reflect.KType object TestHelpers { private const val OPEN_API_ENDPOINT = "/openapi.json" @@ -43,8 +44,8 @@ object TestHelpers { * exists as expected, and that the content matches the expected blob found in the specified file * @param snapshotName The snapshot file to retrieve from the resources folder */ - private suspend fun ApplicationTestBuilder.compareOpenAPISpec(snapshotName: String) { - val response = client.get(OPEN_API_ENDPOINT) + private suspend fun ApplicationTestBuilder.compareOpenAPISpec(rootPath: String, snapshotName: String) { + val response = client.get("$rootPath$OPEN_API_ENDPOINT") response shouldHaveStatus HttpStatusCode.OK response.bodyAsText() shouldNot beBlank() response.bodyAsText() shouldEqualJson getFileSnapshot(snapshotName) @@ -61,11 +62,36 @@ object TestHelpers { customTypes: Map = emptyMap(), applicationSetup: Application.() -> Unit = { }, specOverrides: OpenApiSpec.() -> OpenApiSpec = { this }, + applicationEnvironmentBuilder: ApplicationEngineEnvironmentBuilder.() -> Unit = {}, routeUnderTest: Routing.() -> Unit ) { - openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, applicationSetup, specOverrides, customTypes) - openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, applicationSetup, specOverrides, customTypes) - openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, applicationSetup, specOverrides, customTypes) + openApiTest( + snapshotName, + SupportedSerializer.KOTLINX, + routeUnderTest, + applicationSetup, + specOverrides, + customTypes, + applicationEnvironmentBuilder + ) + openApiTest( + snapshotName, + SupportedSerializer.JACKSON, + routeUnderTest, + applicationSetup, + specOverrides, + customTypes, + applicationEnvironmentBuilder + ) + openApiTest( + snapshotName, + SupportedSerializer.GSON, + routeUnderTest, + applicationSetup, + specOverrides, + customTypes, + applicationEnvironmentBuilder + ) } private fun openApiTest( @@ -74,8 +100,10 @@ object TestHelpers { routeUnderTest: Routing.() -> Unit, applicationSetup: Application.() -> Unit, specOverrides: OpenApiSpec.() -> OpenApiSpec, - typeOverrides: Map = emptyMap() + typeOverrides: Map = emptyMap(), + applicationBuilder: ApplicationEngineEnvironmentBuilder.() -> Unit = {} ) = testApplication { + environment(applicationBuilder) install(NotarizedApplication()) { customTypes = typeOverrides spec = defaultSpec().specOverrides() @@ -105,6 +133,7 @@ object TestHelpers { redoc() routeUnderTest() } - compareOpenAPISpec(snapshotName) + val root = ApplicationEngineEnvironmentBuilder().apply(applicationBuilder).rootPath + compareOpenAPISpec(root, snapshotName) } }