ktorio/ktor

Intergate Swagger UI Hosting as Ktor Feature

JLLeitschuh opened this issue ยท 20 comments

It would be really nice if Ktor could support hosting a Swagger UI that is generated from your routes configuration.

For example, the following could be used to generate a Swagger UI.

data class PetModel(val id: Int?, val name: String)

data class PetsModel(val pets: MutableList<PetModel>)

val data = PetsModel(mutableListOf(PetModel(1, "max"), PetModel(2, "moritz")))
fun newId() = ((data.pets.map { it.id ?: 0 }.max()) ?: 0) + 1

@Group("pet operations")
@Location("/pets/{id}")
class pet(val id: Int)

@Group("pet operations")
@Location("/pets")
class pets

@Group("debug")
@Location("/request/info")
class requestInfo

@Group("debug")
@Location("/request/withHeader")
class withHeader

class Header(val optionalHeader: String?, val mandatoryHeader: Int)

@Group("debug")
@Location("/request/withQueryParameter")
class withQueryParameter

class QueryParameter(val optionalParameter: String?, val mandatoryParameter: Int)

fun main(args: Array<String>) {
    val server = embeddedServer(Netty, getInteger("server.port", 8080)) {
        install(DefaultHeaders)
        install(Compression)
        install(CallLogging)
        install(ContentNegotiation) {
            gson {
                setPrettyPrinting()
            }
        }
        install(Locations)
        install(SwaggerSupport) {
            forwardRoot = true
            swagger.info = Information(
                version = "0.1",
                title = "sample api implemented in ktor",
                description = "This is a sample which combines [ktor](https://github.com/Kotlin/ktor) with [swaggerUi](https://swagger.io/). You find the sources on [github](https://github.com/nielsfalk/ktor-swagger)",
                contact = Contact(
                    name = "Niels Falk",
                    url = "https://nielsfalk.de"
                )
            )
        }
        routing {
            get<pets>("all".responds(ok<PetsModel>())) {
                call.respond(data)
            }
            post<pets, PetModel>("create".responds(ok<PetModel>())) { _, entity ->
                // http201 would be better but there is no way to do this see org.jetbrains.ktor.gson.GsonSupport.renderJsonContent
                call.respond(entity.copy(id = newId()).apply {
                    data.pets.add(this)
                })
            }
            get<pet>("find".responds(ok<PetModel>(), notFound())) { params ->
                data.pets.find { it.id == params.id }
                    ?.let {
                        call.respond(it)
                    }
            }
            put<pet, PetModel>("update".responds(ok<PetModel>(), notFound())) { params, entity ->
                if (data.pets.removeIf { it.id == params.id && it.id == entity.id }) {
                    data.pets.add(entity)
                    call.respond(entity)
                }
            }
            delete<pet>("delete".responds(ok<Unit>(), notFound())) { params ->
                if (data.pets.removeIf { it.id == params.id }) {
                    call.respond(Unit)
                }
            }
            get<requestInfo>(
                responds(ok<Unit>()),
                respondRequestDetails()
            )
            get<withQueryParameter>(
                responds(ok<Unit>())
                    .parameter<QueryParameter>(),
                respondRequestDetails()
            )
            get<withHeader>(
                responds(ok<Unit>())
                    .header<Header>(),
                respondRequestDetails()
            )
        }
    }
    server.start(wait = true)
}

fun respondRequestDetails(): suspend PipelineContext<Unit, ApplicationCall>.(Any) -> Unit {
    return {
        call.respond(
            mapOf(
                "parameter" to call.parameters,
                "header" to call.request.headers
            ).format()
        )
    }
}

private fun Map<String, StringValues>.format() =
    mapValues {
        it.value.toMap()
            .flatMap { (key, value) -> value.map { key to it } }
            .map { (key, value) -> "$key: $value" }
            .joinToString(separator = ",\n")
    }
        .map { (key, value) -> "$key:\n$value" }
        .joinToString(separator = "\n\n")

This is the swagger UI generated from the above.

screen shot 2018-06-26 at 10 11 29 pm

I spent today overhauling @nielsfalk's project ktor-swagger to use the newest version of Ktor and also use Gradle to build the application PR here.

I think this project has quite a bit of potential and could satisfy a need in the community by allowing for a fast way to create documentation for API's written using Ktor.

If the Ktor team would like to adopt this project as a feature, I'm happy to try to make the port from the external project it is today into this repository.

If the interest does not exist to adopt a new feature, I totally understand. The concern that I have with publishing this myself (or with @nielsfalk assistance) is the issue of incompatible breaking changes in Ktor (as Ktor is pre-1.0).

I open the floor to the developers of this project. I'd love to see this integrated as a fully supported feature, but I understand if this is outside the scope of this project.

Some of the concerns that I have with this code base as it is is that it generates all of the schemas from kotlin data classes and expects that some JSON content negotiation feature be installed (ie. Jackson or Gson).

The downside of this is that you are unable to define your JSON schemas externally for objects easily (for example, you would like to put custom descriptions on fields and apply JSON schema validation).

Currently, the way the feature is written the Swagger object is expected to be converted by SOME content negotiator.

typealias ModelName = String
typealias PropertyName = String
typealias Path = String
typealias Definitions = MutableMap<ModelName, ModelData>
typealias Paths = MutableMap<Path, Methods>
typealias MethodName = String
typealias HttpStatus = String
typealias Methods = MutableMap<MethodName, Operation>

class Swagger {
    val swagger = "2.0"
    var info: Information? = null
    val paths: Paths = mutableMapOf()
    val definitions: Definitions = mutableMapOf()
}

class Information(
    val description: String? = null,
    val version: String? = null,
    val title: String? = null,
    val contact: Contact? = null
)

data class Tag(
    val name: String
)

class Contact(
    val name: String? = null,
    val url: String? = null,
    val email: String? = null
)

class Operation(
    metadata: Metadata,
    val responses: Map<HttpStatus, Response>,
    val parameters: List<Parameter>,
    location: Location,
    group: Group?,
    method: HttpMethod,
    locationType: KClass<*>,
    entityType: KClass<*>
) {
    val tags = group?.toList()
    val summary = metadata.summary ?: "${method.value} ${location.path}"
}

class ModelData(val properties: Map<PropertyName, Property>)

The above structure generates the JSON that looks like the following when GSON is installed.

{
  "swagger": "2.0",
  "info": {
    "description": "This is a sample which combines [ktor](https://github.com/Kotlin/ktor) with [swaggerUi](https://swagger.io/). You find the sources on [github](https://github.com/nielsfalk/ktor-swagger)",
    "version": "0.1",
    "title": "sample api implemented in ktor",
    "contact": {
      "name": "Niels Falk",
      "url": "https://nielsfalk.de"
    }
  },
  "paths": {
    "/pets": {
      "get": {
        "tags": [
          {
            "name": "pet operations"
          }
        ],
        "summary": "all",
        "responses": {
          "200": {
            "description": "PetsModel",
            "schema": {
              "$ref": "#/definitions/PetsModel"
            }
          }
        },
        "parameters": []
      },
      "post": {
        "tags": [
          {
            "name": "pet operations"
          }
        ],
        "summary": "create",
        "responses": {
          "201": {
            "description": "PetModel",
            "schema": {
              "$ref": "#/definitions/PetModel"
            }
          }
        },
        "parameters": [
          {
            "name": "body",
            "in": "body",
            "description": "PetModel",
            "required": true,
            "schema": {
              "$ref": "#/definitions/PetModel"
            }
          }
        ]
      }
    },
    "/pets/{id}": {
      "get": {
        "tags": [
          {
            "name": "pet operations"
          }
        ],
        "summary": "find",
        "responses": {
          "200": {
            "description": "PetModel",
            "schema": {
              "$ref": "#/definitions/PetModel"
            }
          },
          "404": {
            "description": "Not Found"
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "id",
            "required": true,
            "type": "integer",
            "format": "int32"
          }
        ]
      },
      "put": {
        "tags": [
          {
            "name": "pet operations"
          }
        ],
        "summary": "update",
        "responses": {
          "200": {
            "description": "PetModel",
            "schema": {
              "$ref": "#/definitions/PetModel"
            }
          },
          "404": {
            "description": "Not Found"
          }
        },
        "parameters": [
          {
            "name": "body",
            "in": "body",
            "description": "PetModel",
            "required": true,
            "schema": {
              "$ref": "#/definitions/PetModel"
            }
          },
          {
            "name": "id",
            "in": "path",
            "description": "id",
            "required": true,
            "type": "integer",
            "format": "int32"
          }
        ]
      },
      "delete": {
        "tags": [
          {
            "name": "pet operations"
          }
        ],
        "summary": "delete",
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Not Found"
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "id",
            "required": true,
            "type": "integer",
            "format": "int32"
          }
        ]
      }
    },
    "/request/info": {
      "get": {
        "tags": [
          {
            "name": "debug"
          }
        ],
        "summary": "GET /request/info",
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "parameters": []
      }
    },
    "/request/withQueryParameter": {
      "get": {
        "tags": [
          {
            "name": "debug"
          }
        ],
        "summary": "GET /request/withQueryParameter",
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "parameters": [
          {
            "name": "mandatoryParameter",
            "in": "query",
            "description": "mandatoryParameter",
            "required": true,
            "type": "integer",
            "format": "int32"
          },
          {
            "name": "optionalParameter",
            "in": "query",
            "description": "optionalParameter",
            "required": false,
            "type": "string"
          }
        ]
      }
    },
    "/request/withHeader": {
      "get": {
        "tags": [
          {
            "name": "debug"
          }
        ],
        "summary": "GET /request/withHeader",
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "parameters": [
          {
            "name": "mandatoryHeader",
            "in": "header",
            "description": "mandatoryHeader",
            "required": true,
            "type": "integer",
            "format": "int32"
          },
          {
            "name": "optionalHeader",
            "in": "header",
            "description": "optionalHeader",
            "required": false,
            "type": "string"
          }
        ]
      }
    }
  },
  "definitions": {
    "PetsModel": {
      "properties": {
        "pets": {
          "type": "array",
          "items": {
            "description": "PetModel",
            "$ref": "#/definitions/PetModel"
          }
        }
      }
    },
    "PetModel": {
      "properties": {
        "id": {
          "type": "integer",
          "format": "int32"
        },
        "name": {
          "type": "string"
        }
      }
    }
  }
}

You could fix this perhaps by defining:

typealias Definitions = MutableMap<ModelName, Any>

And allowing a user to define either a class or pass a custom schema as a string.

Another component that is missing from this example that I'd really like in my application is some sort of JSON Schema validation before the content is deserialized by the content negotiator.
That way you could have the JSON schema validator provide more intelligent validation than Gson or Jackson could provide.

I'm looking for some feedback from @cy6erGn0m or @orangy before I proceed with a PR.
This will require quite a bit of work to integrate this into the ktor repository and I don't want to dedicate the time to do so if the PR will be rejected as an undesired feature.

Are there plans to release this?

@orangefiredragon It's currently released here:
https://github.com/nielsfalk/ktor-swagger

I'd love to integrate it officially.

Currently the project is 90% developed for my own use case at my company. The API is also pretty fluid currently as I'm still figuring out where we are missing things/need new functionality.

But in its current state it's very functional.

galex commented

I'd love to see this integrated into Ktor. Automatic API docs is great!

Really hope to see the official GO from Ktor crew.

@JLLeitschuh @nielsfalk I tried the example code at the top of this issue thread, but I'm running into "$ref: must be a string (JSON-Ref)" errors whenever I click to expand any of the operations in the Swagger UI page. I'm trying to use ktor-swagger with Ktor 1.1.3 and Jackson 2.9.8. Am I doing something wrong, or is this an incompatibility with the latest version of Ktor?

See this ktor-swagger issue for details: nielsfalk/ktor-swagger#29

Thanks.

This missing feature is the only thing keeping my company from adopting Ktor. Until then we're using Spring and SpringFox.

Huge props to @JLLeitschuh for the work he did. I'd love to see it (or a similar implementation) merged into Ktor itself.

I looked around online for a public roadmap but couldn't find one; it'd be nice to at least know if the Ktor/Kotlin/JetBrains team has a plan for this. Even just a "more/less than 6 months" estimate would be great.

There hasn't been any discussion about this with me or anyone else from the JB team about integrating this.

Hey :) Thanks for your Effort @JLLeitschuh I just tried your library, and was a little shocked that I needed to use the standard ktor routing functions like get, post and other stuff from your lib just to get the openapi-docs. I reverted the integration, sorry. Are there plans for integrating OpenAPI docs in ktor ? How can I generate those docs the ktor way in August 2019 @cy6erGn0m ?

I'm no longer working for the company that was actually using this library (just moved from working for HPE to working for Gradle). As such, this is no longer the biggest priority for me anymore unfortunately.

I'm happy to help anyone else out who wants to add this support to Ktor. As for feature development, I can no longer offer anything more than PR review support and feedback at this point.

Sorry.

Any updates on this? This is really good, it belongs in the official repo! @cy6erGn0m @orangy

I agree it would be great to hear at least something official back given that this has nearly 80 thumbs-up.

CC @cy6erGn0m @orangy @e5l

yes, exactly. please at least let us know if this will be added or not. that will be very helpful.

any update on this?

Any updates on this one yet?

SerVB commented

I'm using this variant: https://github.com/papsign/Ktor-OpenAPI-Generator. It's pretty handy but requires testing and discussions with the author. Please join!

stale commented

This issue has been automatically marked as stale because it has not had recent activity.

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.

What is the status of this feature?
The best project kompendium that gave OAS support to Ktor is not supporting Ktor 2.0.0 because of this issue...
kompendium