Repository: bkbnio/kompendium (kotlin)

Issue:
Nullable enum type in schema leads to error
**Describe the bug**
If I have in response a enum field that is nullable, I'm getting such error:

> Exception in thread "main" java.lang.IllegalArgumentException: Slug cannot be null for an enum schema! EnumSchema(enum=[<...enum values...>], default=null, description=null, nullable=true)
> at io.bkbn.kompendium.core.handler.SchemaHandler$DefaultImpls.postProcessSchema(SchemaHandler.kt:33)


**To Reproduce**
1. Add nullable enum field to response
2. Use response in API call that generates OpenAPI documentation
3. Launch KTor - exception will be thrown on start

**Expected behavior**
There should be no any errors or recommendation how to solve this problem

Pull request:
# Description

Fix bug where nullable enum fields caused runtime exception

Closes #233 

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] 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?

Added new unit test

# 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 6fb42d109c..858b1b3aa0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@
 
 ## Released
 
+## [2.3.2] - March 30th, 2022
+### Changed
+- Fixed bug where nullable enum fields caused runtime exceptions
+
 ## [2.3.1] - March 5th, 2022
 ### Changed
 - Can now apply `@FreeFormObject` to top level types
diff --git a/gradle.properties b/gradle.properties
index 546f216a6d..e40a04655f 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
 # Kompendium
-project.version=2.3.1
+project.version=2.3.2
 # Kotlin
 kotlin.code.style=official
 # Gradle
diff --git a/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/schema/EnumSchema.kt b/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/schema/EnumSchema.kt
index abe701fff8..7750af1b88 100644
--- a/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/schema/EnumSchema.kt
+++ b/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/schema/EnumSchema.kt
@@ -5,10 +5,27 @@ import kotlinx.serialization.Serializable
 
 @Serializable
 data class EnumSchema(
-  val `enum`: Set<String>,
+  val enum: Set<String>,
   override val default: @Contextual Any? = null,
   override val description: String? = null,
   override val nullable: Boolean? = null
 ) : TypedSchema {
   override val type: String = "string"
+
+  override fun equals(other: Any?): Boolean {
+    if (other !is EnumSchema) return false
+    if (enum != other.enum) return false
+    // TODO Going to need some way to differentiate nullable vs non-nullable reference schemas 😬
+    // if (nullable != other.nullable) return false
+    return true
+  }
+
+  override fun hashCode(): Int {
+    var result = enum.hashCode()
+    result = 31 * result + (default?.hashCode() ?: 0)
+    result = 31 * result + (description?.hashCode() ?: 0)
+    result = 31 * result + (nullable?.hashCode() ?: 0)
+    result = 31 * result + type.hashCode()
+    return result
+  }
 }
diff --git a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt
index ccd092b4a4..12b8074094 100644
--- a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt
+++ b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt
@@ -77,7 +77,7 @@ private fun Application.mainModule() {
     swagger(pageTitle = "Swaggerlicious")
     // Kompendium infers the route path from the Ktor Route.  This will show up as the root path `/`
     notarizedGet(simpleGetExample) {
-      call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString()))
+      call.respond(HttpStatusCode.OK, BasicResponse(c = UUID.randomUUID().toString(), d = null))
     }
     notarizedDelete(simpleDeleteRequest) {
       call.respond(HttpStatusCode.NoContent)
@@ -87,15 +87,15 @@ private fun Application.mainModule() {
       notarizedGet(simpleGetExampleWithParameters) {
         val a = call.parameters["a"] ?: error("Unable to read expected path parameter")
         val b = call.request.queryParameters["b"]?.toInt() ?: error("Unable to read expected query parameter")
-        call.respond(HttpStatusCode.OK, BasicResponse(c = "$a: $b"))
+        call.respond(HttpStatusCode.OK, BasicResponse(c = "$a: $b", d = BasicModels.BasicEnum.NO))
       }
     }
     route("/create") {
       notarizedPost(simplePostRequest) {
         val request = call.receive<BasicRequest>()
         when (request.d) {
-          true -> call.respond(HttpStatusCode.OK, BasicResponse(c = "So it is true!"))
-          false -> call.respond(HttpStatusCode.OK, BasicResponse(c = "Oh, I knew it!"))
+          true -> call.respond(HttpStatusCode.OK, BasicResponse(c = "So it is true!", d = null))
+          false -> call.respond(HttpStatusCode.OK, BasicResponse(c = "Oh, I knew it!", d = BasicModels.BasicEnum.YES))
         }
       }
     }
@@ -115,7 +115,7 @@ object BasicPlaygroundToC {
     responseInfo = ResponseInfo(
       status = HttpStatusCode.OK,
       description = "This means everything went as expected!",
-      examples = mapOf("demo" to BasicResponse(c = "52c099d7-8642-46cc-b34e-22f39b923cf4"))
+      examples = mapOf("demo" to BasicResponse(c = "52c099d7-8642-46cc-b34e-22f39b923cf4", BasicModels.BasicEnum.NO))
     ),
     tags = setOf("Simple")
   )
@@ -131,7 +131,7 @@ object BasicPlaygroundToC {
     responseInfo = ResponseInfo(
       status = HttpStatusCode.OK,
       description = "This means everything went as expected!",
-      examples = mapOf("demo" to BasicResponse(c = "52c099d7-8642-46cc-b34e-22f39b923cf4"))
+      examples = mapOf("demo" to BasicResponse(c = "52c099d7-8642-46cc-b34e-22f39b923cf4", BasicModels.BasicEnum.YES))
     ),
     tags = setOf("Parameters")
   )
@@ -149,7 +149,7 @@ object BasicPlaygroundToC {
     responseInfo = ResponseInfo(
       status = HttpStatusCode.OK,
       description = "This means everything went as expected!",
-      examples = mapOf("demo" to BasicResponse(c = "So it is true!"))
+      examples = mapOf("demo" to BasicResponse(c = "So it is true!", null))
     ),
     tags = setOf("Simple")
   )
@@ -169,8 +169,15 @@ object BasicPlaygroundToC {
 }
 
 object BasicModels {
+
+  @Serializable
+  enum class BasicEnum {
+    YES,
+    NO
+  }
+
   @Serializable
-  data class BasicResponse(val c: String)
+  data class BasicResponse(val c: String, val d: BasicEnum? = null)
 
   @Serializable
   data class BasicParameters(
diff --git a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/GsonSerializationPlayground.kt b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/GsonSerializationPlayground.kt
index 04ee780a33..89dc39f2ba 100644
--- a/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/GsonSerializationPlayground.kt
+++ b/kompendium-playground/src/main/kotlin/io/bkbn/kompendium/playground/GsonSerializationPlayground.kt
@@ -40,8 +40,8 @@ private fun Application.mainModule() {
       notarizedPost(BasicPlaygroundToC.simplePostRequest) {
         val request = call.receive<BasicModels.BasicRequest>()
         when (request.d) {
-          true -> call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = "So it is true!"))
-          false -> call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = "Oh, I knew it!"))
+          true -> call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = "So it is true!", null))
+          false -> call.respond(HttpStatusCode.OK, BasicModels.BasicResponse(c = "Oh, I knew it!", null))
         }
       }
     }


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 e827ee7ec9..4b36a1e2fa 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
@@ -41,6 +41,7 @@ import io.bkbn.kompendium.core.util.notarizedOptionsModule
 import io.bkbn.kompendium.core.util.notarizedPatchModule
 import io.bkbn.kompendium.core.util.notarizedPostModule
 import io.bkbn.kompendium.core.util.notarizedPutModule
+import io.bkbn.kompendium.core.util.nullableEnumField
 import io.bkbn.kompendium.core.util.nullableField
 import io.bkbn.kompendium.core.util.nullableNestedObject
 import io.bkbn.kompendium.core.util.overrideFieldInfo
@@ -245,6 +246,9 @@ class KompendiumTest : DescribeSpec({
     it("Nullable fields do not lead to doom") {
       openApiTestAllSerializers("nullable_fields.json") { nullableNestedObject() }
     }
+    it("Can have a nullable enum as a member field") {
+      openApiTestAllSerializers("nullable_enum_field.json") { nullableEnumField() }
+    }
   }
   describe("Constraints") {
     it("Can set a minimum and maximum integer value") {
diff --git a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt
index f77d4d6c4c..c8f61a55b1 100644
--- a/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt
+++ b/kompendium-core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt
@@ -429,6 +429,16 @@ fun Application.nullableNestedObject() {
   }
 }
 
+fun Application.nullableEnumField() {
+  routing {
+    route("/nullable/enum") {
+      notarizedGet(TestResponseInfo.nullableEnumField) {
+        call.respond(HttpStatusCode.OK)
+      }
+    }
+  }
+}
+
 fun Application.constrainedIntInfo() {
   routing {
     route("/test/constrained_int") {
diff --git a/kompendium-core/src/test/resources/nullable_enum_field.json b/kompendium-core/src/test/resources/nullable_enum_field.json
new file mode 100644
index 0000000000..a100ca747a
--- /dev/null
+++ b/kompendium-core/src/test/resources/nullable_enum_field.json
@@ -0,0 +1,73 @@
+{
+  "openapi": "3.0.3",
+  "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",
+      "description": "Production instance of my API"
+    },
+    {
+      "url": "https://staging.myawesomeapi.com",
+      "description": "Where the fun stuff happens"
+    }
+  ],
+  "paths": {
+    "/nullable/enum": {
+      "get": {
+        "tags": [],
+        "summary": "Has a nullable enum field",
+        "description": "should still work!",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "A successful endeavor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NullableEnum"
+                }
+              }
+            }
+          }
+        },
+        "deprecated": false
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "NullableEnum": {
+        "properties": {
+          "a": {
+            "$ref": "#/components/schemas/TestEnum"
+          }
+        },
+        "type": "object"
+      },
+      "TestEnum": {
+        "enum": [
+          "YES",
+          "NO"
+        ],
+        "type": "string"
+      }
+    },
+    "securitySchemes": {}
+  },
+  "security": [],
+  "tags": []
+}
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt
index 5cee52a81e..f237f293f6 100644
--- a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt
@@ -63,6 +63,15 @@ data class TestRequest(
 @Serializable
 data class TestResponse(val c: String)
 
+@Serializable
+enum class TestEnum {
+  YES,
+  NO
+}
+
+@Serializable
+data class NullableEnum(val a: TestEnum? = null)
+
 data class TestGeneric<T>(val messy: String, val potato: T)
 
 data class TestCreatedResponse(val id: Int, val c: String)
diff --git a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt
index e10b752dc3..0760715239 100644
--- a/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt
+++ b/kompendium-core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestResponseInfo.kt
@@ -185,6 +185,12 @@ object TestResponseInfo {
     responseInfo = simpleOkResponse()
   )
 
+  val nullableEnumField = GetInfo<Unit, NullableEnum>(
+    summary = "Has a nullable enum field",
+    description = "should still work!",
+    responseInfo = simpleOkResponse()
+  )
+
   val minMaxInt = GetInfo<Unit, MinMaxInt>(
     summary = "Constrained int field",
     description = "Cool stuff",