ktor icon indicating copy to clipboard operation
ktor copied to clipboard

Intergate Swagger UI Hosting as Ktor Feature

Open JLLeitschuh opened this issue 6 years ago • 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.

JLLeitschuh avatar Jun 27 '18 02:06 JLLeitschuh

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.

JLLeitschuh avatar Jun 27 '18 22:06 JLLeitschuh

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.

JLLeitschuh avatar Jun 28 '18 20:06 JLLeitschuh

Are there plans to release this?

orangefiredragon avatar Aug 12 '18 00:08 orangefiredragon

@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.

JLLeitschuh avatar Aug 12 '18 01:08 JLLeitschuh

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

galex avatar Jan 20 '19 04:01 galex

Really hope to see the official GO from Ktor crew.

astoialtsev avatar Jan 23 '19 13:01 astoialtsev

@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: https://github.com/nielsfalk/ktor-swagger/issues/29

Thanks.

volkert-fastned avatar Mar 25 '19 10:03 volkert-fastned

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.

noahbetzen-wk avatar Apr 23 '19 07:04 noahbetzen-wk

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

JLLeitschuh avatar Apr 23 '19 14:04 JLLeitschuh

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 ?

snackycracky avatar Aug 26 '19 09:08 snackycracky

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.

JLLeitschuh avatar Aug 26 '19 14:08 JLLeitschuh

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

Aditya94A avatar Nov 10 '19 17:11 Aditya94A

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

Globegitter avatar Dec 02 '19 22:12 Globegitter

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

kemalizing avatar Dec 23 '19 12:12 kemalizing

any update on this?

dragneelfps avatar Feb 14 '20 11:02 dragneelfps

Any updates on this one yet?

ramrock93 avatar Mar 19 '20 15:03 ramrock93

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!

SerVB avatar Mar 21 '20 09:03 SerVB

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

stale[bot] avatar Jun 19 '20 10:06 stale[bot]

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

oleg-larshin avatar Aug 10 '20 15:08 oleg-larshin

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

Marek00Malik avatar Apr 20 '22 19:04 Marek00Malik