Repository: bkbnio/kompendium (kotlin)

Issue:
Jackson serializer requires Include.NON_NULL option
**Is your feature request related to a problem? Please describe.**
Kompendium currently uses the installed serializer.
For Jackson it requires the `setSerializationInclusion(JsonInclude.Include.NON_NULL)` option to display ReDoc and Swagger UI correctly.
This option might not be wanted for the installed serializer used by Ktor.


**Describe the solution you'd like**
How about providing an option to pass a separate serializer instance.


**Describe alternatives you've considered**
An alternative would be to not require specific serializer options for Kompendium.

Pull request:
# Description

Enables users who need to leverage custom serializers to explicitly override the `/openapi.json` endpoint to fit their needs

Closes #185 

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation 
- [ ] Chore

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

# Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] I have updated the CHANGELOG in the `Unreleased` section
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f04ef99e6b..7630555947 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 ## Unreleased
 
 ### Added
+- Ability to override serializer via custom route
 
 ### Changed
 
diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kompendium.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kompendium.kt
index 7c7a527c04..c2b5fee945 100644
--- a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kompendium.kt
+++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Kompendium.kt
@@ -4,12 +4,14 @@ 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.routing.Routing
+import io.ktor.routing.get
+import io.ktor.routing.route
+import io.ktor.routing.routing
 import io.ktor.util.AttributeKey
 import kotlin.reflect.KClass
 
@@ -19,7 +21,13 @@ class Kompendium(val config: Configuration) {
     lateinit var spec: OpenApiSpec
 
     var cache: SchemaMap = mutableMapOf()
-    var specRoute = "/openapi.json"
+    var openApiJson: Routing.(OpenApiSpec) -> Unit = { spec ->
+      route("/openapi.json") {
+        get {
+          call.respond(HttpStatusCode.OK, spec)
+        }
+      }
+    }
 
     // TODO Add tests for this!!
     fun addCustomTypeSchema(clazz: KClass<*>, schema: TypedSchema) {
@@ -31,13 +39,8 @@ class Kompendium(val config: Configuration) {
     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)
-        }
-      }
-
+      val routing = pipeline.routing { }
+      configuration.openApiJson(routing, configuration.spec)
       return Kompendium(configuration)
     }
   }
diff --git a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SerializerOverridePlayground.kt b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SerializerOverridePlayground.kt
new file mode 100644
index 0000000000..8aeaad1f50
--- /dev/null
+++ b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/SerializerOverridePlayground.kt
@@ -0,0 +1,85 @@
+package io.bkbn.kompendium.playground
+
+import io.bkbn.kompendium.core.Kompendium
+import io.bkbn.kompendium.core.Notarized.notarizedGet
+import io.bkbn.kompendium.core.metadata.ResponseInfo
+import io.bkbn.kompendium.core.metadata.method.GetInfo
+import io.bkbn.kompendium.core.routes.swagger
+import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
+import io.bkbn.kompendium.playground.Customization.customSerializer
+import io.bkbn.kompendium.playground.SerializerOverridePlaygroundToC.getExample
+import io.bkbn.kompendium.playground.util.Util
+import io.ktor.application.Application
+import io.ktor.application.call
+import io.ktor.application.install
+import io.ktor.features.ContentNegotiation
+import io.ktor.http.HttpStatusCode
+import io.ktor.response.respond
+import io.ktor.response.respondText
+import io.ktor.routing.get
+import io.ktor.routing.route
+import io.ktor.routing.routing
+import io.ktor.serialization.json
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.netty.Netty
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+fun main() {
+  embeddedServer(
+    Netty,
+    port = 8081,
+    module = Application::mainModule
+  ).start(wait = true)
+}
+
+private fun Application.mainModule() {
+  install(ContentNegotiation) {
+    json(Json {
+      encodeDefaults = false
+    })
+  }
+  install(Kompendium) {
+    spec = Util.baseSpec
+    openApiJson = { spec ->
+      route("/openapi.json") {
+        get {
+          call.respondText { customSerializer.encodeToString(spec) }
+        }
+      }
+    }
+  }
+  routing {
+    swagger(pageTitle = "Docs")
+    notarizedGet(getExample) {
+      call.respond(HttpStatusCode.OK, SerializerOverrideModel.OhYeaCoolData(null))
+    }
+  }
+}
+
+object SerializerOverridePlaygroundToC {
+  val getExample = GetInfo<Unit, SerializerOverrideModel.OhYeaCoolData>(
+    summary = "Overriding the serializer",
+    description = "Pretty neat!",
+    responseInfo = ResponseInfo(
+      status = HttpStatusCode.OK,
+      description = "This means everything went as expected!",
+      examples = mapOf("demo" to SerializerOverrideModel.OhYeaCoolData(null))
+    ),
+    tags = setOf("Custom")
+  )
+}
+
+object SerializerOverrideModel {
+  @Serializable
+  data class OhYeaCoolData(val num: Int?, val test: String = "gonezo")
+}
+
+object Customization {
+  val customSerializer = Json {
+    serializersModule = KompendiumSerializersModule.module
+    encodeDefaults = true
+    explicitNulls = false
+  }
+}


Test patch:
diff --git a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt
index 9524889f15..6aefbfb07c 100644
--- a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt
+++ b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt
@@ -1,8 +1,13 @@
 package io.bkbn.kompendium.core
 
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.databind.ObjectMapper
 import io.bkbn.kompendium.core.fixtures.TestHelpers.apiFunctionalityTest
+import io.bkbn.kompendium.core.fixtures.TestHelpers.compareOpenAPISpec
 import io.bkbn.kompendium.core.fixtures.TestHelpers.getFileSnapshot
 import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
+import io.bkbn.kompendium.core.fixtures.TestSpecs.defaultSpec
+import io.bkbn.kompendium.core.fixtures.docs
 import io.bkbn.kompendium.core.util.complexType
 import io.bkbn.kompendium.core.util.constrainedDoubleInfo
 import io.bkbn.kompendium.core.util.constrainedIntInfo
@@ -53,9 +58,22 @@ import io.bkbn.kompendium.core.util.uniqueArray
 import io.bkbn.kompendium.core.util.withDefaultParameter
 import io.bkbn.kompendium.core.util.withExamples
 import io.bkbn.kompendium.core.util.withOperationId
+import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
 import io.kotest.core.spec.style.DescribeSpec
+import io.ktor.application.call
+import io.ktor.application.install
+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.get
+import io.ktor.routing.route
+import io.ktor.serialization.json
+import io.ktor.server.testing.withTestApplication
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
 
 class KompendiumTest : DescribeSpec({
   describe("Notarized Open API Metadata Tests") {
@@ -258,4 +276,59 @@ class KompendiumTest : DescribeSpec({
       openApiTestAllSerializers("free_form_object.json") { freeFormObject() }
     }
   }
+  describe("Serialization overrides") {
+    it("Can override the jackson serializer") {
+      withTestApplication({
+        install(Kompendium) {
+          spec = defaultSpec()
+          openApiJson = { spec ->
+            val om = ObjectMapper().apply {
+              setSerializationInclusion(JsonInclude.Include.NON_NULL)
+            }
+            route("/openapi.json") {
+              get {
+                call.respondText { om.writeValueAsString(spec) }
+              }
+            }
+          }
+        }
+        install(ContentNegotiation) {
+          jackson(ContentType.Application.Json)
+        }
+        docs()
+        withExamples()
+      }) {
+        compareOpenAPISpec("example_req_and_resp.json")
+      }
+    }
+    it("Can override the kotlinx serializer") {
+      withTestApplication({
+        install(Kompendium) {
+          spec = defaultSpec()
+          openApiJson = { spec ->
+            val om = ObjectMapper().apply {
+              setSerializationInclusion(JsonInclude.Include.NON_NULL)
+            }
+            route("/openapi.json") {
+              get {
+                val customSerializer = Json {
+                  serializersModule = KompendiumSerializersModule.module
+                  encodeDefaults = true
+                  explicitNulls = false
+                }
+                call.respondText { customSerializer.encodeToString(spec) }
+              }
+            }
+          }
+        }
+        install(ContentNegotiation) {
+          json()
+        }
+        docs()
+        withExamples()
+      }) {
+        compareOpenAPISpec("example_req_and_resp.json")
+      }
+    }
+  }
 })
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt
index 3e1818cb96..0b5163f292 100644
--- a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt
@@ -35,7 +35,7 @@ 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 fun TestApplicationEngine.compareOpenAPISpec(snapshotName: String) {
+  fun TestApplicationEngine.compareOpenAPISpec(snapshotName: String) {
     // act
     handleRequest(HttpMethod.Get, OPEN_API_ENDPOINT).apply {
       // assert
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModules.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModules.kt
index 191820b367..ec9ec6e493 100644
--- a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModules.kt
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModules.kt
@@ -3,19 +3,14 @@ package io.bkbn.kompendium.core.fixtures
 import com.fasterxml.jackson.annotation.JsonInclude
 import com.fasterxml.jackson.databind.SerializationFeature
 import io.bkbn.kompendium.core.Kompendium
+import io.bkbn.kompendium.core.fixtures.TestSpecs.defaultSpec
 import io.bkbn.kompendium.core.routes.redoc
-import io.bkbn.kompendium.oas.OpenApiSpec
-import io.bkbn.kompendium.oas.info.Contact
-import io.bkbn.kompendium.oas.info.Info
-import io.bkbn.kompendium.oas.info.License
-import io.bkbn.kompendium.oas.server.Server
 import io.ktor.application.Application
 import io.ktor.application.install
 import io.ktor.features.ContentNegotiation
 import io.ktor.http.ContentType
 import io.ktor.jackson.jackson
 import io.ktor.routing.routing
-import java.net.URI
 
 fun Application.docs() {
   routing {
@@ -34,32 +29,6 @@ fun Application.jacksonConfigModule() {
 
 fun Application.kompendium() {
   install(Kompendium) {
-    spec = OpenApiSpec(
-      info = Info(
-        title = "Test API",
-        version = "1.33.7",
-        description = "An amazing, fully-ish 😉 generated API spec",
-        termsOfService = URI("https://example.com"),
-        contact = Contact(
-          name = "Homer Simpson",
-          email = "chunkylover53@aol.com",
-          url = URI("https://gph.is/1NPUDiM")
-        ),
-        license = License(
-          name = "MIT",
-          url = URI("https://github.com/bkbnio/kompendium/blob/main/LICENSE")
-        )
-      ),
-      servers = mutableListOf(
-        Server(
-          url = URI("https://myawesomeapi.com"),
-          description = "Production instance of my API"
-        ),
-        Server(
-          url = URI("https://staging.myawesomeapi.com"),
-          description = "Where the fun stuff happens"
-        )
-      )
-    )
+    spec = defaultSpec()
   }
 }
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestSpecs.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestSpecs.kt
new file mode 100644
index 0000000000..b842f2095e
--- /dev/null
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestSpecs.kt
@@ -0,0 +1,40 @@
+package io.bkbn.kompendium.core.fixtures
+
+import io.bkbn.kompendium.oas.OpenApiSpec
+import io.bkbn.kompendium.oas.info.Contact
+import io.bkbn.kompendium.oas.info.Info
+import io.bkbn.kompendium.oas.info.License
+import io.bkbn.kompendium.oas.server.Server
+import java.net.URI
+
+object TestSpecs {
+  val defaultSpec: () -> OpenApiSpec = {
+    OpenApiSpec(
+      info = Info(
+        title = "Test API",
+        version = "1.33.7",
+        description = "An amazing, fully-ish 😉 generated API spec",
+        termsOfService = URI("https://example.com"),
+        contact = Contact(
+          name = "Homer Simpson",
+          email = "chunkylover53@aol.com",
+          url = URI("https://gph.is/1NPUDiM")
+        ),
+        license = License(
+          name = "MIT",
+          url = URI("https://github.com/bkbnio/kompendium/blob/main/LICENSE")
+        )
+      ),
+      servers = mutableListOf(
+        Server(
+          url = URI("https://myawesomeapi.com"),
+          description = "Production instance of my API"
+        ),
+        Server(
+          url = URI("https://staging.myawesomeapi.com"),
+          description = "Where the fun stuff happens"
+        )
+      )
+    )
+  }
+}