Browse Source

add default properties, some work on response types

main
James Fenn 6 months ago
parent
commit
0631920d55
8 changed files with 235 additions and 25 deletions
  1. +84
    -11
      docs/src/me/jfenn/ktordocs/RestDocs.kt
  2. +25
    -3
      docs/src/me/jfenn/ktordocs/RestDocsFeature.kt
  3. +10
    -0
      docs/src/me/jfenn/ktordocs/model/Configuration.kt
  4. +35
    -6
      docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt
  5. +22
    -0
      docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt
  6. +14
    -0
      docs/src/me/jfenn/ktordocs/model/ResponseInfo.kt
  7. +9
    -0
      docs/src/me/jfenn/ktordocs/util/String.kt
  8. +36
    -5
      example/src/Application.kt

+ 84
- 11
docs/src/me/jfenn/ktordocs/RestDocs.kt View File

@@ -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) }
}
}
}
}
}


+ 25
- 3
docs/src/me/jfenn/ktordocs/RestDocsFeature.kt View File

@@ -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<RouteSelector> = 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)
}



+ 10
- 0
docs/src/me/jfenn/ktordocs/model/Configuration.kt View File

@@ -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()
}

}

+ 35
- 6
docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt View File

@@ -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<String, ParameterInfo>()

internal val responses = HashMap<HttpStatusCode, ResponseInfo>()

fun param(name: String, configure: ParameterInfo.() -> Unit = {}) {
params[name] = (params[name] ?: ParameterInfo(name)).apply { configure() }
}

// val params: ArrayList<ParameterInfo>
fun responds(code: HttpStatusCode = HttpStatusCode.OK, configure: ResponseInfo.() -> Unit = {}) {
responses[code] = (responses[code] ?: ResponseInfo(code)).apply { configure() }
}

// val responses: ArrayList<ResponseInfo>
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()
}
}
}

}

+ 22
- 0
docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt View File

@@ -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"
}
}

+ 14
- 0
docs/src/me/jfenn/ktordocs/model/ResponseInfo.kt View File

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

}

+ 9
- 0
docs/src/me/jfenn/ktordocs/util/String.kt View File

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

+ 36
- 5
example/src/Application.kt View File

@@ -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<String>): 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"
}
}
}
}


Loading…
Cancel
Save