SMILEY4 / ktor-swagger-ui

Kotlin Ktor plugin to generate OpenAPI and provide Swagger UI
Apache License 2.0
182 stars 33 forks source link

The customProcessor setting isn't functioning for LocalDateTime and LocalDate. #135

Closed kchinburarat closed 1 month ago

kchinburarat commented 1 month ago

I have a proof-of-concept project with a data class like the one below. Note: ktor-swagger-ui : 3.4.0 schema_kenerator: 1.4.0

@Serializable
class ClassWithLocalDate(
    @Serializable(with = LocalDateSerializer::class)
    @SerialName("DoB")
    val dateOfBirth: LocalDate,
    @SerialName("DoBWithTime")
    @Serializable(with = LocalDateTimeSerializer::class)
    val dateOfBirthWithTime: LocalDateTime
)

@OptIn(ExperimentalSerializationApi::class)
@Serializer(forClass = LocalDateTime::class)
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
    private val formatter = DateTimeFormatter.ISO_LOCAL_DATE

    override fun serialize(encoder: Encoder, value: LocalDateTime) {
        encoder.encodeString(value.format(formatter))
    }

    override fun deserialize(decoder: Decoder): LocalDateTime {
        return LocalDateTime.parse(decoder.decodeString(), formatter)
    }
}

object LocalDateSerializer : KSerializer<LocalDate> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: LocalDate) {
        val string = value.format(DateTimeFormatter.ISO_DATE)
        encoder.encodeString(string)
    }

    override fun deserialize(decoder: Decoder): LocalDate {
        val string = decoder.decodeString()
        return LocalDate.parse(string)
    }

}

then i try to configure swagger option like

install(SwaggerUI) {
        info {
            title = "Example API"
        }
        schemas {
            // replace default schema-generator with customized one
            generator = { type ->
                type
                    // process type using kotlinx-serialization instead of reflection
                    // requires additional dependency "io.github.smiley4:schema-kenerator-kotlinx-serialization:<VERSION>"
                    // see https://github.com/SMILEY4/schema-kenerator for more information
                    .processKotlinxSerialization {
                        customProcessor<LocalDateTime> {
                            createDefaultPrimitiveTypeData<String>(mutableMapOf(
                                "format" to "date-time",
                                "type" to "string"
                            ))
                        }

                        customProcessor<LocalDate> {
                            createDefaultPrimitiveTypeData<String>(mutableMapOf(
                                "format" to "yyyy-MM-dd",
                                "type" to "string"
                            ))
                        }
                    }
                    .generateSwaggerSchema()
                    .withTitle(TitleType.SIMPLE)
                    .handleCoreAnnotations()
                    .handleSwaggerAnnotations()
                    .handleSchemaAnnotations()
                    .compileReferencingRoot()

            }

        }
    }

    // helper function

private inline fun <reified T> createDefaultPrimitiveTypeData(values: MutableMap<String, Any?>): PrimitiveTypeData {
    return PrimitiveTypeData(
        id = TypeId.build(T::class.qualifiedName!!),
        simpleName = T::class.simpleName!!,
        qualifiedName = T::class.qualifiedName!!,
        annotations = mutableListOf(
            AnnotationData(
                name = "date",
                values = values,
                annotation = null
            )
        )
    )
}

// Route
get("/ClassWithLocalDate" , {
            response {
                // information about a "200 OK" response
                code(HttpStatusCode.OK) {
                    // body of the response
                    body<ClassWithLocalDate>()
                }
            }
        }) {
            call.respond(
                ClassWithLocalDate(
                    dateOfBirth = java.time.LocalDate.now() ,
                    dateOfBirthWithTime = java.time.LocalDateTime.now()
                )
            )
        }

I expect the api.json to set the type to string, but it is currently null. image

I'm not sure if I've missed something!

I would like to set type as string and format as date-time

Could you help :)

SMILEY4 commented 1 month ago

Hi,

There are a three things i spotted - assuming you want to use the built-in "handleSchemaAnnotations()"-step to change the type of the swagger-schema (though this is still a bit of a tricky and limited feature)

  1. to change the swagger-schema-type of a class/type with the built-in "handleSchemaAnnotations()"-step, the annotation of the class/type must be the "SwaggerTypeHint"-annotation with an entry in the value-map: "type to xyz".
private inline fun <reified T> createDefaultPrimitiveTypeData(): PrimitiveTypeData {
    return PrimitiveTypeData(
        id = TypeId.build(T::class.qualifiedName!!),
        simpleName = T::class.simpleName!!,
        qualifiedName = T::class.qualifiedName!!,
        annotations = mutableListOf(
            AnnotationData(
                name = SwaggerTypeHint::class.qualifiedName!!, // use the @SwaggerTypeHint-annotation
                values = mutableMapOf(
                    "type" to "date" // specify the type
                ),
                annotation = null
            )
        )
    )
}
  1. the custom processor looks for a serial descriptor with the provided name. If it's specified as a type parameter, i.e. as customProcessor<LocalDate> it looks for one with the same name as the qualified name of the LocalDate-class (= "java.time.LocalDate"). The "LocalDateSerializer" however changes the name of the serial descriptor to just "LocalDate" (here: PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)), no longer matching the one for the custom processor. You would either rename the serial descriptor in the LocalDateSerializer or specify the name for the custom processor as customProcessor("LocalDate") {.

With these two changes the result would be this ...

{
  "root" : {
    "$ref" : "#/components/schemas/io.github.smiley4.schemakenerator.test.Test.Companion.ClassWithLocalDate",
    "exampleSetFlag" : false
  },
  "componentSchemas" : {
    "io.github.smiley4.schemakenerator.test.Test.Companion.ClassWithLocalDate" : {
      "title" : "ClassWithLocalDate",
      "required" : [ "DoB", "DoBWithTime" ],
      "type" : "object",
      "properties" : {
        "DoB" : {
          "title" : "String",
          "type" : "date",
          "exampleSetFlag" : false
        },
        "DoBWithTime" : {
          "title" : "String",
          "type" : "date",
          "exampleSetFlag" : false
        }
      },
      "exampleSetFlag" : false
    }
  }
}
  1. Both custom PrimitiveTypeData, i.e. the one for LocalDate and LocalDateTime are created with the same type-id due to both being created as "String" here createDefaultPrimitiveTypeData< String >(mutableMapOf( resulting in duplicate ids here id = TypeId.build(T::class.qualifiedName!!),. Since ids are treated as unique, one of them gets dropped.

...without the "format". Adding this would currently be a bit more tricky, since its not supported in the "SwaggerTypeHint"-annotation (maybe i should change that :thinking: )

Alternative

Another option to support also the "format" property would be to write your own "marker" annotation together with a custom step.

Creating the primitive type, but adding a "custom" annotation e.g. with name "swagger_type_and_format" and the required type & format information:

private inline fun <reified T> createDefaultPrimitiveTypeData(format: String): PrimitiveTypeData {
    return PrimitiveTypeData(
        id = TypeId.build(T::class.qualifiedName!!),
        simpleName = T::class.simpleName!!,
        qualifiedName = T::class.qualifiedName!!,
        annotations = mutableListOf(
            AnnotationData(
                name = "type_format_annotation",
                values = mutableMapOf(
                    "type" to "date",
                    "format" to format
                ),
                annotation = null
            )
        )
    )
}

Replace the "handleSchemaAnnotations()" with your own one handling the custom annotation and modifying the swagger schema:

.customizeTypes { typeData, typeSchema ->
    typeData.annotations.find { it.name == "type_format_annotation" }?.also { annotation ->
        typeSchema.format = annotation.values["format"]?.toString()
        typeSchema.type = annotation.values["type"]?.toString()
    }
}

Make sure the TypeIds don't collide by e.g. calling the create function like this: createDefaultPrimitiveTypeData<LocalDateTime> and createDefaultPrimitiveTypeData<LocalDate>

This would then produce:

{
  "root" : {
    "$ref" : "#/components/schemas/io.github.smiley4.schemakenerator.test.Test.Companion.ClassWithLocalDate",
    "exampleSetFlag" : false
  },
  "componentSchemas" : {
    "io.github.smiley4.schemakenerator.test.Test.Companion.ClassWithLocalDate" : {
      "title" : "ClassWithLocalDate",
      "required" : [ "DoB", "DoBWithTime" ],
      "type" : "object",
      "properties" : {
        "DoB" : {
          "title" : "LocalDate",
          "type" : "date",
          "format" : "yyyy-MM-dd",
          "exampleSetFlag" : false
        },
        "DoBWithTime" : {
          "title" : "LocalDateTime",
          "type" : "date",
          "format" : "date-time",
          "exampleSetFlag" : false
        }
      },
      "exampleSetFlag" : false
    }
  }
}

I hope i could help. I know this is still a bit weird to use and i'm still thinking how to best support this use case. If you have any more questions or need more of the example source just let me know :)

kchinburarat commented 1 month ago

@SMILEY4, after applying your suggestion to my POC project, it seems to be working perfectly. image

Thanks for the incredibly fast support!

Below is the finalized code.

 // Data class

@Serializable

class ClassWithLocalDate(
    @Serializable(with = LocalDateSerializer::class)
    @SerialName("DoB")
    @Example("2024-10-07")
    val dateOfBirth: LocalDate,
    @SerialName("DoBWithTime")
    @Serializable(with = LocalDateTimeSerializer::class)
    @Example("2024-10-07")
    val dateOfBirthWithTime: LocalDateTime
)

@OptIn(ExperimentalSerializationApi::class)
@Serializer(forClass = LocalDateTime::class)
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
    private val formatter = DateTimeFormatter.ISO_LOCAL_DATE

    override fun serialize(encoder: Encoder, value: LocalDateTime) {
        encoder.encodeString(value.format(formatter))
    }

    override fun deserialize(decoder: Decoder): LocalDateTime {
        return LocalDateTime.parse(decoder.decodeString(), formatter)
    }
}

object LocalDateSerializer : KSerializer<LocalDate> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: LocalDate) {
        val string = value.format(DateTimeFormatter.ISO_DATE)
        encoder.encodeString(string)
    }

    override fun deserialize(decoder: Decoder): LocalDate {
        val string = decoder.decodeString()
        return LocalDate.parse(string)
    }

}

//  plugin instalation
install(SwaggerUI) {
        info {
            title = "Example API"
        }
        schemas {
            // replace default schema-generator with customized one
            generator = { type ->
                type
                    .processKotlinxSerialization {
                        customProcessor<LocalDateTime> {
                            createDefaultPrimitiveTypeData<LocalDateTime>()
                        }
                        customProcessor("LocalDate") {
                            createDefaultPrimitiveTypeData<LocalDate>()
                        }
                    }
                    .connectSubTypes()
                    .generateSwaggerSchema()
                    .withTitle(TitleType.SIMPLE)
                    .handleCoreAnnotations()
                    //.handleSwaggerAnnotations()
                    .customizeTypes { typeData, typeSchema ->
                        typeData.annotations.find { it.name == "type_format_annotation" }?.also { annotation ->
                            typeSchema.format = annotation.values["format"]?.toString()
                            typeSchema.type = annotation.values["type"]?.toString()
                        }
                    }
                    .handleSchemaAnnotations()
                    .compileReferencingRoot()
            }
        }
    }

/***
 * Issue : https://github.com/SMILEY4/ktor-swagger-ui/issues/135
 */
private inline fun <reified T> createDefaultPrimitiveTypeData(format: String = "date"): PrimitiveTypeData {
    return PrimitiveTypeData(
        id = TypeId.build(T::class.qualifiedName!!),
        simpleName = T::class.simpleName!!,
        qualifiedName = T::class.qualifiedName!!,
        annotations = mutableListOf(
            AnnotationData(
                name = "type_format_annotation",
                values = mutableMapOf(
                    "type" to "string", // specify the type
                    "format" to format // specify the format
                ),
                annotation = null
            )
        )
    )
}

// Routing
get("/ClassWithLocalDate" , {
            response {
                // information about a "200 OK" response
                code(HttpStatusCode.OK) {
                    // body of the response
                    body<ClassWithLocalDate>{
                        description = "Class with LocalDate"
                        example("DOg"){
                            ClassWithLocalDate(
                                dateOfBirth = LocalDate.now() ,
                                dateOfBirthWithTime = LocalDateTime.now()
                            )
                        }
                    }

                }
                code(HttpStatusCode.NotFound) {
                    description = "the pet with the given id was not found"
                }
            }

        }) {
            call.respond(
                ClassWithLocalDate(
                    dateOfBirth = java.time.LocalDate.now() ,
                    dateOfBirthWithTime = java.time.LocalDateTime.now()
                )
            )
        }