Repository: bkbnio/kompendium
Pull request #383 (2022-11-14 19:50:13)

fix: use rootPath on NotarizedRoute
# Description

It's a common pattern to use `ktor` application with `rootPath` on deployment configuration. This means that all routes into `routing` will start with the same base-path. These changes add the improvement to detect application environment root path and replace it in the autogenerated path of routes.

### Note
It only changes the `calculateRoutePath` method

## Type of change

Please delete options that are not relevant.

- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] Chore

# How Has This Been Tested?

1. Create a simple application with root path [`ktor.deployment.rootPath`](https://ktor.io/docs/configurations.html#predefined-properties)
2. Put server with context into OpenApi Spec 
3. Create routes

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] I have updated the CHANGELOG in the `Unreleased` section
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules


tbert12: Hi! Thank you for the comment.

The `rootPath` is commonly used into microservices infrastructure which has a unique balancer in the front that routes the request under a path. As example:
```
                 /----[path /foo]---> foo-api (rootPath is /foo)
req ---> balancer
                 \----[path /bar] ---> bar-api (rootPath is /bar)
```

In these cases to be more cleaner with the open api spec and the swagger ui. It's recommended to use the server with the base path [link](https://swagger.io/docs/specification/api-host-and-base-path/). And then, all paths on the swagger will not contain this part of the path.

These changes fix it 🙂!

unredundant: yea that makes sense, cool, thanks for explaining :) 

Could you just add a quick line explaining the fix in the `CHANGELOG.md`? Then I'll go ahead and merge this in.  

Patch:
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 .*\\)")) }


Test patch:
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<KType, JsonSchema> = 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<KType, JsonSchema> = emptyMap()
+    typeOverrides: Map<KType, JsonSchema> = 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)
   }
 }