diff --git a/docs/src/me/jfenn/ktordocs/RestDocs.kt b/docs/src/me/jfenn/ktordocs/RestDocs.kt index fb4194fa840dbb68d0a12b3d17931d16bd81345a..f17d0c6202bb59420a49743fa53d26736f62dcbf 100644 --- a/docs/src/me/jfenn/ktordocs/RestDocs.kt +++ b/docs/src/me/jfenn/ktordocs/RestDocs.kt @@ -5,6 +5,8 @@ import kotlinx.html.* import kotlinx.html.stream.appendHTML import me.jfenn.ktordocs.model.Configuration import me.jfenn.ktordocs.model.EndpointInfo +import me.jfenn.ktordocs.model.ParameterInfo +import me.jfenn.ktordocs.util.slugify class RestDocs( configure: Configuration.() -> Unit @@ -19,8 +21,15 @@ class RestDocs( } fun FlowContent.endpointInfo(endpoint: EndpointInfo) { - div("card my-3") { - h5("card-header") { + div("my-5") { + h4 { + id = endpoint.id + a(href = "#" + endpoint.id) { +endpoint.title } + } + endpoint.description?.let { + p("text-muted") { +it } + } + div("alert bg-light") { span("mr-3 badge badge-" + when (endpoint.method) { HttpMethod.Get -> "primary" HttpMethod.Post -> "success" @@ -35,9 +44,56 @@ class RestDocs( +endpoint.path } } - div("card-body") { - endpoint.description?.let { - p("card-text") { +it } + + if (endpoint.params.isNotEmpty()) { + h5 { +"Parameters" } + table("table") { + style = "font-size: 0.8rem;" + + thead { + tr { + th { +"Name" } + th { +"Type" } + th { +"In" } + th { +"Description" } + } + } + tbody { + endpoint.params.forEach { (name, param) -> + tr { + td { + span("text-monospace") { +name } + if (param.isRequired) { + span("text-danger") { +"*" } + } + } + td("text-muted") { +param.type } + td("text-muted") { +param.location.value } + td("text-muted") { +param.description } + } + } + } + } + } + + h5 { +"Code samples" } + pre("alert bg-light") { + code { + style = "tab-size: 2;" + + +buildString { + appendln("curl -X ${endpoint.method.value} \\") + endpoint.params.filterValues { it.location == ParameterInfo.In.Header }.forEach { + appendln("\t-H '${it.value.name}: ${it.value.example}' \\") + } + appendln("\t${config.baseUrl}${endpoint.path}") + } + } + } + + if (endpoint.responses.isNotEmpty()) { + endpoint.responses.forEach { (code, response) -> + } } } @@ -56,13 +112,30 @@ class RestDocs( ) } body { - div("container my-5") { - div("jumbotron mb-5") { - h1("display-4") { +config.title } - p("lead") { +config.description } - } + div("row m-0") { + div("col-12 col-md-3 bg-dark text-light") { + div("py-4") { + style = "position: sticky; top: 0;" + + div("px-2") { + h3 { +config.title } + p { +config.description } + + h4 { +"Contents" } + } - endpoints.forEach { endpointInfo(it) } + endpoints.forEach { + div("p-2 border-bottom border-secondary") { + a(classes = "text-light", href = "#" + it.id) { +it.title } + } + } + } + } + div("col-12 col-md-9") { + div("container my-5") { + endpoints.forEach { endpointInfo(it) } + } + } } } } diff --git a/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt b/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt index 6ba857bb6d3c0394f82a753c2e30fd84f7f17501..413cf78714c9b6203c512482c96a11a3aa001950 100644 --- a/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt +++ b/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt @@ -8,9 +8,11 @@ import io.ktor.util.AttributeKey import io.ktor.util.pipeline.PipelineContext import io.ktor.util.pipeline.PipelineInterceptor import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.jfenn.ktordocs.model.Configuration import me.jfenn.ktordocs.model.EndpointInfo +import me.jfenn.ktordocs.model.ParameterInfo import kotlin.coroutines.CoroutineContext import kotlin.reflect.full.memberProperties @@ -34,9 +36,28 @@ class RestDocsFeature( suspend fun updateRoutes(route: Route, selectors: List = listOf()) { (route.selector as? HttpMethodRouteSelector)?.let { selector -> val path = buildPathString(selectors) - val endpoint = EndpointInfo(path, selector.method) - - val testPath = buildPathString(selectors, defaultParam = "ktor_docs") + val endpoint = docs.config.defaultEndpoint.copy( + path = path, + method = selector.method + ) + + // insert known path params + selectors.forEach { + when (it) { + is PathSegmentParameterRouteSelector -> endpoint.param(it.name) { + location = ParameterInfo.In.Path + isRequired = true + } + is PathSegmentOptionalParameterRouteSelector -> endpoint.param(it.name) { + location = ParameterInfo.In.Path + } + is PathSegmentTailcardRouteSelector -> endpoint.param(it.name) { + location = ParameterInfo.In.Path + isRequired = true + type = "vararg" + } + } + } // using reflection to obtain the "route.handlers" property val handlers = Route::class.memberProperties.find { @@ -80,6 +101,7 @@ class RestDocsFeature( fun Route.restDocumentation(path: String = "/") { GlobalScope.launch { + delay(500) application.feature(RestDocsFeature).updateRoutes(this@restDocumentation) } diff --git a/docs/src/me/jfenn/ktordocs/model/Configuration.kt b/docs/src/me/jfenn/ktordocs/model/Configuration.kt index 596c3628126a4642a06b286f502544074a728f62..579fcd5104b4fcbd43cbab6f8366265810f7690d 100644 --- a/docs/src/me/jfenn/ktordocs/model/Configuration.kt +++ b/docs/src/me/jfenn/ktordocs/model/Configuration.kt @@ -1,8 +1,18 @@ package me.jfenn.ktordocs.model +import io.ktor.http.HttpMethod + class Configuration { + var baseUrl = "http://localhost:8080" + var title = "REST API" var description = "This documentation describes the website's API endpoints." + internal var defaultEndpoint = EndpointInfo("/", HttpMethod.Get) + + fun defaultProperties(configure: EndpointInfo.() -> Unit) { + defaultEndpoint.configure() + } + } \ No newline at end of file diff --git a/docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt b/docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt index b38559457a3e1323fc738bbb88741808fc1ad072..da03937a21abbf757da08950b31f734a4a40580c 100644 --- a/docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt +++ b/docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt @@ -1,19 +1,48 @@ package me.jfenn.ktordocs.model import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import me.jfenn.ktordocs.util.slugify class EndpointInfo( val path: String, - val method: HttpMethod + val method: HttpMethod, + var title: String = path, + var description: String? = null ) { - var description: String? = null + val id get() = title.slugify() + + internal val params = HashMap() + + internal val responses = HashMap() + + fun param(name: String, configure: ParameterInfo.() -> Unit = {}) { + params[name] = (params[name] ?: ParameterInfo(name)).apply { configure() } + } - // val params: ArrayList + fun responds(code: HttpStatusCode = HttpStatusCode.OK, configure: ResponseInfo.() -> Unit = {}) { + responses[code] = (responses[code] ?: ResponseInfo(code)).apply { configure() } + } - // val responses: ArrayList + fun copy( + path: String = this.path, + method: HttpMethod = this.method + ) : EndpointInfo { + return EndpointInfo( + path = path, + method = method, + title = title, + description = description + ).also { + params.forEach { (name, param) -> + it.params[name] = param.copy() + } - // fun param(...) - // fun responds(...) + responses.forEach { (code, response) -> + it.responses[code] = response.copy() + } + } + } } \ No newline at end of file diff --git a/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt b/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt new file mode 100644 index 0000000000000000000000000000000000000000..bf76c2c3771fb3956df24815bc200f790a3a4066 --- /dev/null +++ b/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt @@ -0,0 +1,22 @@ +package me.jfenn.ktordocs.model + +data class ParameterInfo( + val name: String, + var description: String = "No description provided.", + var type: String = TYPE_STRING, + var isRequired: Boolean = false, + var location: In = In.Query, + var example: String = "{${name}}" +) { + + sealed class In(val value: String) { + object Header : In("header") + object Path : In("path") + object Query : In("query") + } + + companion object { + const val TYPE_STRING = "string" + const val TYPE_INTEGER = "integer" + } +} \ No newline at end of file diff --git a/docs/src/me/jfenn/ktordocs/model/ResponseInfo.kt b/docs/src/me/jfenn/ktordocs/model/ResponseInfo.kt new file mode 100644 index 0000000000000000000000000000000000000000..2d88fef5f762b624e8ca9f1c9ea1559c6423ed7b --- /dev/null +++ b/docs/src/me/jfenn/ktordocs/model/ResponseInfo.kt @@ -0,0 +1,14 @@ +package me.jfenn.ktordocs.model + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode + +data class ResponseInfo( + val code: HttpStatusCode, + var description: String? = null, + var example: String? = null +) { + + // TODO: header properties + +} \ No newline at end of file diff --git a/docs/src/me/jfenn/ktordocs/util/String.kt b/docs/src/me/jfenn/ktordocs/util/String.kt new file mode 100644 index 0000000000000000000000000000000000000000..342bdc8c8ba8cf110a39c92832193dbc57d7e122 --- /dev/null +++ b/docs/src/me/jfenn/ktordocs/util/String.kt @@ -0,0 +1,9 @@ +package me.jfenn.ktordocs.util + +fun String.slugify() : String { + return this.toLowerCase() + .replace(Regex("[^a-z0-9\\s]"), "") // remove any non-alphanumeric chars + .take(100) // max length of 100 (longer file/dir names cause problems on some systems) + .trim() + .replace(Regex("\\s+"), "-") // replace groups of whitespace with hyphens +} diff --git a/example/src/Application.kt b/example/src/Application.kt index 08416c52ae762698066101cf63d27ff9f1ae79bc..637fc22e4dcb72cfbf8d18bd6035f6b5f608273a 100644 --- a/example/src/Application.kt +++ b/example/src/Application.kt @@ -2,6 +2,7 @@ package me.jfenn import io.ktor.application.* import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.response.* import io.ktor.request.* import io.ktor.routing.get @@ -10,22 +11,36 @@ import io.ktor.routing.route import io.ktor.routing.routing import me.jfenn.ktordocs.RestDocsFeature import me.jfenn.ktordocs.docs +import me.jfenn.ktordocs.model.ParameterInfo import me.jfenn.ktordocs.restDocumentation fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) @Suppress("unused") // Referenced in application.conf @kotlin.jvm.JvmOverloads -fun Application.module(testing: Boolean = false) { +fun Application.module() { install(RestDocsFeature) { - // TODO: config options + baseUrl = "https://example.com" + + title = "Example API" + description = "Basic documentation generated for testing & demo purposes." + + defaultProperties { + param("accept") { + description = "The content type to return - such as 'application/json'" + location = ParameterInfo.In.Header + type = "Content Type" + example = "application/json" + } + } } routing { route("/api") { get("/hello") { docs { + title = "Hello world" description = "Responds with 'hello world' :)" } @@ -34,13 +49,29 @@ fun Application.module(testing: Boolean = false) { get("/user/{username?}") { docs { - description = "Returns a user object." + title = "Get user info" + description = "Returns an object representation of the requested user ID." + param("username") { + type = "SHA1 hash" + description = "A valid ID or username of the user to fetch." + } + responds(HttpStatusCode.OK) { + description = "If the user has been found" + } } } - post("/user/{username?}") { + post("/user/{username}") { docs { - description = "Updates the provided user info." + title = "Modify user info" + description = "Updates the provided user info in the database." + param("username") { + type = "SHA1 hash" + description = "A valid ID or username of the user to update." + } + responds(HttpStatusCode.OK) { + description = "If the user was successfully updated" + } } } }