SMILEY4 / ktor-swagger-ui

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

Handling Date Body Parameters? #44

Closed ScottPierce closed 1 year ago

ScottPierce commented 1 year ago

Right now if I use https://github.com/Kotlin/kotlinx-datetime Instant, it shows up in swagger as {}. I don't see any way to change this.

Is there a way that I can define a type somewhere globally, and use that definition everywhere the type is used? This would be very useful for Date types, and I'm sure others as well.

chrimaeon commented 1 year ago

+1

Also when using example which has a any type of kotlinx.datetime like

OssProject(
  name = "my-project",
  description = "My Open Source Project",
  url = "https://cmgapps.com",
  topics = listOf(
    "kotlin",
    "android",
    "kotlin multiplatform",
  ),
  stars = 42,
  private = false,
  fork = false,
  archived = false,
  pushedAt = Instant.fromEpochMilliseconds(305143200000),
)

will show up as

"OSS-Projects" : {
  "value" : [ {
    "name" : "my-project",
    "description" : "My Open Source Project",
    "url" : "https://cmgapps.com",
    "topics" : [ "kotlin", "android", "kotlin multiplatform" ],
    "stars" : 42,
    "private" : false,
    "fork" : false,
    "archived" : false,
    "pushedAt" : {
      "epochSeconds" : 305143200,
      "nanosecondsOfSecond" : 0,
      "value$kotlinx_datetime" : "1979-09-02T18:00:00Z"
    }
  } ]
}    
ScottPierce commented 1 year ago

Oh wow, I didn't realize that was how to use examples. Copied you, and I'm getting the same problem.

ScottPierce commented 1 year ago

So it looks like this library is heavily based on https://victools.github.io/jsonschema-generator/#introduction

Any type override is likely going to go through that, which can be accessed via

    install(SwaggerUI) {
        schemaGeneratorConfigBuilder

I've tried several things for the past few hours, and I haven't been able to get anything working. I'm sure it's something simple. @SMILEY4 you seem very well acquainted with the library. Do you think you could point us in the right direction?

ScottPierce commented 1 year ago

This is a partial fix... It only fixes the default examples, but doesn't fix it when I provide an example object like you above.

        schemaGeneratorConfigBuilder.also {
                it.forTypesInGeneral()
                    .withTypeAttributeOverride { objectNode: ObjectNode, typeScope: TypeScope, _: SchemaGenerationContext ->
                        if (typeScope.type.erasedType == Instant::class.java) {
                            objectNode
                                .put("type", "string")
                                .put("format", "date-time")
                        }
                    }
            }
SMILEY4 commented 1 year ago

Hi, @ScottPierce your solution would the the one that i thought of aswell. I think you could also set example-values and types with an @Schema-annotation, but that would also only work for the automatic/default examples. The problem with the custom examples is, that the objects are passed directly to the openapi-library, without any more (obvious) option to customize the object. I'll try to look into it

ScottPierce commented 1 year ago

@SMILEY4 How could I change the way your library serializes with jackson for a type?

SMILEY4 commented 1 year ago

One workaround i'm currently investigating is to modify the jackson-object-mapper (see io.swagger.v3.core.util.Json) that is used to serialize these examples and the api-spec. One possible (temporary?) solution could look something like this (serializing Instant to a simple timestamp):

// custom serializer for kotlinx "instant"-type
class KotlinXInstantSerializer : StdSerializer<Instant>(Instant::class.java) {
    override fun serialize(value: Instant, jgen: JsonGenerator, provider: SerializerProvider) {
        jgen.writeNumber(value.toEpochMilliseconds()) // write instant as epoch-milliseconds in json
    }
}
// register custom serializer with the swagger objectmapper
SimpleModule().addSerializer(Instant::class.java, KotlinXInstantSerializer()).also {
    io.swagger.v3.core.util.Json.mapper().registerModule(it)
}
ScottPierce commented 1 year ago

I just tried this. It doesn't seem to work.

To be clear this is happening in:

request {
    body<GetTestsByStoreRequest> {
        required = true
        val now = Clock.System.now()
        example(
            name = "Get tests for the last 24 hours",
            value = GetTestsByStoreRequest(
                storeId = 999999999,
                startDate = now - 24.hours,
                endDate = now,
            )
        )
    }
}
image
SMILEY4 commented 1 year ago

can and look at this standalone test code and see if that works or helps:

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import io.github.smiley4.ktorswaggerui.SwaggerUI
import io.github.smiley4.ktorswaggerui.dsl.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respond
import io.ktor.server.routing.routing
import kotlinx.datetime.Instant

fun main() {
    embeddedServer(Netty, port = 8080, host = "localhost", module = Application::myModule).start(wait = true)
}

private fun Application.myModule() {

    SimpleModule().addSerializer(Instant::class.java, KotlinXInstantSerializer()).also {
        io.swagger.v3.core.util.Json.mapper().registerModule(it)
    }

    install(SwaggerUI)

    routing {
        get("dataWithKotlinX", {
            request {
                body<DataWithKotlinX> {
                    example(
                        "example", DataWithKotlinX(
                            text = "some-string",
                            counter = 42,
                            instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
                        )
                    )
                }
            }
        }) {
            call.respond(status = HttpStatusCode.NotImplemented, "...")
        }
    }
    /*
          example in swagger shown as:
          {
            "text": "some-string",
            "instant": 1684164345760,
            "counter": 42
          }
    */
}

data class DataWithKotlinX(
    val text: String,
    val instant: Instant,
    val counter: Int
)

class KotlinXInstantSerializer : StdSerializer<Instant>(Instant::class.java) {
    override fun serialize(value: Instant, jgen: JsonGenerator, provider: SerializerProvider) {
        jgen.writeNumber(value.toEpochMilliseconds())
    }
}
ScottPierce commented 1 year ago

It works when I set

    SimpleModule().addSerializer(Instant::class.java, KotlinXInstantSerializer()).also {
        io.swagger.v3.core.util.Json.mapper().registerModule(it)
    }

before the endpoints are made, with their examples. If I set it after, it won't work.

ScottPierce commented 1 year ago

@SMILEY4 So this workaround is working for me. Do you think we can expect a better API for this in the future though?

SMILEY4 commented 1 year ago

yes. currently, i only see this as a quick temporary fix. I want to simplify customisation and extenability in general in the future, so this will be a part of that. I just dont know exactly when - maybe sooner, maybe later.

ScottPierce commented 1 year ago

Another workaround that we found. Just using kotlinx.serialization to pretty-print the example:

val EXAMPLE_JSON = Json {
    prettyPrint = true
    encodeDefaults = true
}

example(
    name = "Get all tests for the last 24 hours",
    value = EXAMPLE_JSON.encodeToString(example),
)

We ended up having to do this to get enum values to show properly in our examples, however we weren't able to get the values to properly serialize in our response examples. i.e. our enums are showing with their FULL_CAPS_NAME, instead of the name specified in @SerialName("full-caps-name")

@SMILEY4 While fixing this, please take into account we probably just need a way to use kotlinx.serialization for the resulting examples, and also the resulting example response value.

SMILEY4 commented 1 year ago

Hi, i released a new version (2.0.0-rc) with a refined api that simplifies the process of using custom serializers/encoders. I would greatly appreciate if you could have a look at it and see if it the issues you have with kotlinx and multiplatform. There is currently no documentation/wiki for it, but you can check out this example for some guidance.

ScottPierce commented 1 year ago

I end up with the following error:

Any recommendation to proceed?

Unable to render this definition
The provided definition does not specify a valid version field.

Please indicate a valid Swagger or OpenAPI version field. Supported version fields are swagger: "2.0" and those that match openapi: 3.0.n (for example, openapi: 3.0.0).

image

SMILEY4 commented 1 year ago

this is usually the result of some internal error and hard to tell without further information.

ScottPierce commented 1 year ago

What information do you need? I see no errors in the log. Here is my config:

    install(SwaggerUI) {
        ignoredRouteSelectors = ignoredRouteSelectors + RateLimitRouteSelector::class

        encoding {
            schemaEncoder {
                Json.encodeToSchema(serializer(it), generateDefinitions = false)
            }
            schemaDefinitionsField = "definitions"
            exampleEncoder { type, example ->
                Json.encodeToString(serializer(type!!), example)
            }
        }

        swagger {
            swaggerUrl = "docs"
            forwardRoot = false
            authentication = "docs"
        }
        info {
            title = "Example Partner API (Experimental)"
            version = "0.1.0"
            description = "This API is private, and is subject to sporadic changes."
        }
        server {
            url = "https://api.example.com"
        }
    }
SMILEY4 commented 1 year ago

Thanks. The configuration looks good to me. I'm guessing there is some problem with some model. Could you maybe try to narrow down the bug to a specific model and provide that (if possible).

SMILEY4 commented 1 year ago

I quickly created a new version (2.0.0-rc.2) that should print exceptions that are thrown during api generation. This should now also help with this problem.

ScottPierce commented 1 year ago

Changing the example to not be a String seems to have fixed it. If I submit a String as the example value I get the error.

                example(
                    name = "Get My Stores",
                    value = GetStoresRequest(offset = 0, limit = 100),
                )

Making my example encoder like this would probably fix it:

            exampleEncoder { type, example ->
                if (example !is String) {
                    Json.encodeToString(serializer(type!!), example)
                } else {
                    example
                }
            }

We should fix the exception being eaten in the example encoder.

SMILEY4 commented 1 year ago

Thanks. This ...

                example(
                    name = "Get My Stores",
                    value = GetStoresRequest(offset = 0, limit = 100),
                )

... is the intended way (and as far is i understood also the working one). I think i was able to reproduce the issue uncluding seeing the exception, though im not sure if there was some weird caching going on on your side or if i messed something up. Either way i'll double check again.