Browse Source

add auth config options

main
James Fenn 5 months ago
parent
commit
78fae69fd0
15 changed files with 413 additions and 204 deletions
  1. +1
    -0
      docs/build.gradle
  2. +249
    -0
      docs/src/me/jfenn/ktordocs/HtmlBuilder.kt
  3. +3
    -163
      docs/src/me/jfenn/ktordocs/RestDocs.kt
  4. +16
    -5
      docs/src/me/jfenn/ktordocs/RestDocsFeature.kt
  5. +13
    -0
      docs/src/me/jfenn/ktordocs/interface/HasParams.kt
  6. +13
    -0
      docs/src/me/jfenn/ktordocs/interface/HasReferences.kt
  7. +40
    -0
      docs/src/me/jfenn/ktordocs/model/AuthenticationInfo.kt
  8. +7
    -1
      docs/src/me/jfenn/ktordocs/model/Configuration.kt
  9. +16
    -12
      docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt
  10. +1
    -1
      docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt
  11. +10
    -0
      docs/src/me/jfenn/ktordocs/model/ReferenceInfo.kt
  12. +1
    -1
      docs/src/me/jfenn/ktordocs/model/ResponseInfo.kt
  13. +1
    -0
      example/build.gradle
  14. +41
    -20
      example/src/Application.kt
  15. +1
    -1
      gradle.properties

+ 1
- 0
docs/build.gradle View File

@@ -13,5 +13,6 @@ sourceSets {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "io.ktor:ktor-server-core:$ktor_version"
implementation "io.ktor:ktor-auth:$ktor_version"
implementation "org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.1"
}

+ 249
- 0
docs/src/me/jfenn/ktordocs/HtmlBuilder.kt View File

@@ -0,0 +1,249 @@
package me.jfenn.ktordocs

import io.ktor.http.HttpMethod
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
import me.jfenn.ktordocs.`interface`.HasParams
import me.jfenn.ktordocs.`interface`.HasReferences
import me.jfenn.ktordocs.model.EndpointInfo
import me.jfenn.ktordocs.model.ParameterInfo

class HtmlBuilder(
val docs: RestDocs
) {

private fun DIV.referenceInfo(item: HasReferences) {
if (item.references.isNotEmpty()) {
h5 { +"Links" }
ul {
item.references.forEach { reference ->
li {
a(href = reference.url) { +reference.title }
}
}
}
}
}

private fun DIV.parameterInfo(item: HasParams) {
if (item.params.isNotEmpty()) {
h5 { +"Parameters" }
table("table") {
thead {
tr {
th { +"Name" }
th { +"Type" }
th { +"In" }
th { +"Description" }
}
}
tbody {
item.params.forEach { (name, param) ->
tr {
td {
span("text-monospace font-weight-bold") { +name }
if (param.isRequired) {
span("text-danger") { +"*" }
}
}
td("text-muted") { +param.type }
td("text-muted") { +param.location.value }
td("text-muted") { +param.desc }
}
}
}
}
}
}

private fun DIV.endpointInfo(endpoint: EndpointInfo) {
div("mb-5") {
div {
h4("d-inline") {
id = endpoint.id
a(href = "#" + endpoint.id) { +endpoint.title }
}
if (endpoint.auth.isNotEmpty()) {
span("float-right") {
endpoint.auth.forEach {
button(type = ButtonType.button, classes = "btn btn-link text-monospace") {
attributes["data-toggle"] = "modal"
attributes["data-target"] = "#auth_${it}"

+it
}
}

// "lock" icon
+"\uD83D\uDD12"
}
}
}
endpoint.desc?.let {
p("text-muted") { +it }
}
div("alert bg-light") {
span("mr-3 badge badge-" + when (endpoint.method) {
HttpMethod.Get -> "primary"
HttpMethod.Post -> "success"
HttpMethod.Put -> "warning"
HttpMethod.Patch -> "info"
HttpMethod.Delete -> "danger"
else -> "secondary"
}) {
+endpoint.method.value
}
span("text-monospace") {
+endpoint.path
}
}

parameterInfo(endpoint)

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}' \\")
}

if (endpoint.method == HttpMethod.Post || endpoint.method == HttpMethod.Put)
appendln("\t-d '{data}' \\")

appendln("\t${docs.config.baseUrl}${endpoint.path}")
}
}
}

if (endpoint.responses.isNotEmpty()) {
h5 { +"Responses" }
table("table") {
thead {
tr {
th { +"Code" }
th { +"Description" }
th { +"Value" }
}
}
tbody {
endpoint.responses.forEach { (code, response) ->
tr {
td {
a(href = "https://http.cat/${code.value}") {
span("text-monospace") { +code.value.toString() }
span("text-muted") { +" (${code.description})" }
}
}
td("text-muted") { +response.desc }
td {
response.example?.let {
pre("alert bg-light") {
code {
style = "tab-size: 4;"
+it
}
}
}
}
}
}
}
}
}

referenceInfo(endpoint)
}
}

fun toHtml() : String = buildString {
appendln("<!DOCTYPE html>")
appendHTML().html {
head {
meta(charset = "utf-8")
title(docs.config.title)

link(
href = "https://stackpath.bootstrapcdn.com/bootstrap/4.5.1/css/bootstrap.min.css",
rel = "stylesheet"
)

style {
+"table {font-size:0.8rem;display:inline-block;max-width:100%;overflow-x:auto;}"
}
}
body {
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 { +docs.config.title }
p("text-white-50") { +docs.config.desc }

span("font-weight-bold") { +"Contents" }
}

docs.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-4") {
h1("mb-4") { +"Endpoints" }
docs.endpoints.forEach { endpointInfo(it) }
}
}
}

docs.config.authMethods.forEach { (name, auth) ->
modal("auth_${name}", auth.title) {
p { +auth.desc }

auth.subDesc?.let {
p { small("text-muted") { +it } }
}

parameterInfo(auth)
referenceInfo(auth)
}
}

script(src = "https://code.jquery.com/jquery-3.5.1.slim.min.js") {}
script(src = "https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js") {}
script(src = "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js") {}
}
}
appendln()
}
}

fun FlowContent.modal(modalId: String, modalTitle: String, content: DIV.() -> Unit) {
div("modal fade") {
id = modalId
attributes["tabindex"] = "-1"

div("modal-dialog") {
div("modal-content") {
div("modal-header") {
h5("modal-title") { +modalTitle }
button(type = ButtonType.button, classes = "close") {
attributes["data-dismiss"] = "modal"
unsafe { +"&times;" }
}
}
div("modal-body") {
content()
}
}
}
}
}

+ 3
- 163
docs/src/me/jfenn/ktordocs/RestDocs.kt View File

@@ -3,182 +3,22 @@ package me.jfenn.ktordocs
import io.ktor.http.HttpMethod
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
import me.jfenn.ktordocs.model.AuthenticationInfo
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
) {

val config = Configuration().apply { configure() }
val htmlBuilder = HtmlBuilder(this)

val endpoints = ArrayList<EndpointInfo>()

fun add(info: EndpointInfo) {
fun endpoint(info: EndpointInfo) {
endpoints.add(info)
}

fun FlowContent.endpointInfo(endpoint: EndpointInfo) {
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"
HttpMethod.Put -> "warning"
HttpMethod.Patch -> "info"
HttpMethod.Delete -> "danger"
else -> "secondary"
}) {
+endpoint.method.value
}
span("text-monospace") {
+endpoint.path
}
}

if (endpoint.params.isNotEmpty()) {
h5 { +"Parameters" }
table("table") {
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}' \\")
}

if (endpoint.method == HttpMethod.Post || endpoint.method == HttpMethod.Put)
appendln("\t-d '{data}' \\")

appendln("\t${config.baseUrl}${endpoint.path}")
}
}
}

if (endpoint.responses.isNotEmpty()) {
h5 { +"Responses" }
table("table") {
thead {
tr {
th { +"Code" }
th { +"Description" }
th { +"Value" }
}
}
tbody {
endpoint.responses.forEach { (code, response) ->
tr {
td {
a(href = "https://http.cat/${code.value}") {
span("text-monospace") { +code.value.toString() }
span("text-muted") { +" (${code.description})" }
}
}
td("text-muted") { +(response.description ?: "") }
td {
response.example?.let {
pre("p-3 bg-dark text-light rounded") {
code("py-3") {
style = "tab-size: 4;"
+it
}
}
} ?: run {
+"No example provided."
}
}
}
}
}
}
}
}
}

fun toHtml() : String = buildString {
appendln("<!DOCTYPE html>")
appendHTML().html {
head {
meta(charset = "utf-8")
title(config.title)

link(
href = "https://stackpath.bootstrapcdn.com/bootstrap/4.5.1/css/bootstrap.min.css",
rel = "stylesheet"
)

style {
+"table {font-size:0.8rem;display:inline-block;max-width:100%;overflow-x:auto;}"
}
}
body {
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 {
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) }
}
}
}
}
}
appendln()
}

}

+ 16
- 5
docs/src/me/jfenn/ktordocs/RestDocsFeature.kt View File

@@ -1,6 +1,8 @@
package me.jfenn.ktordocs

import io.ktor.application.*
import io.ktor.auth.AuthenticationProvider
import io.ktor.auth.AuthenticationRouteSelector
import io.ktor.http.ContentType
import io.ktor.response.respondText
import io.ktor.routing.*
@@ -33,10 +35,19 @@ class RestDocsFeature(
}.joinToString("/")
}

suspend fun updateRoutes(route: Route, selectors: List<RouteSelector> = listOf()) {
suspend fun updateRoutes(route: Route, selectors: List<RouteSelector> = listOf(), inheritEndpoint: EndpointInfo = docs.config.defaultEndpoint) {
var newEndpoint = inheritEndpoint

(route.selector as? AuthenticationRouteSelector)?.let { selector ->
// add auth methods
newEndpoint = inheritEndpoint.copy(
authentication = selector.names.filterNotNull()
)
}

(route.selector as? HttpMethodRouteSelector)?.let { selector ->
val path = buildPathString(selectors)
val endpoint = docs.config.defaultEndpoint.copy(
val endpoint = inheritEndpoint.copy(
path = path,
method = selector.method
)
@@ -70,7 +81,7 @@ class RestDocsFeature(
} catch (e: DocsProxyException) {
// caught proxy extension; add endpoint to docs
e.configure.invoke(endpoint)
docs.add(endpoint)
docs.endpoint(endpoint)
return
} catch (e: Throwable) {
// do nothing
@@ -79,7 +90,7 @@ class RestDocsFeature(
}

route.children.forEach {
updateRoutes(it, selectors + it.selector)
updateRoutes(it, selectors + it.selector, newEndpoint)
}
}

@@ -107,7 +118,7 @@ fun Route.restDocumentation(path: String = "/") {

get(path) {
application.feature(RestDocsFeature).apply {
call.respondText(docs.toHtml(), ContentType.Text.Html)
call.respondText(docs.htmlBuilder.toHtml(), ContentType.Text.Html)
}
}
}


+ 13
- 0
docs/src/me/jfenn/ktordocs/interface/HasParams.kt View File

@@ -0,0 +1,13 @@
package me.jfenn.ktordocs.`interface`

import me.jfenn.ktordocs.model.ParameterInfo

interface HasParams {

val params: HashMap<String, ParameterInfo>

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

}

+ 13
- 0
docs/src/me/jfenn/ktordocs/interface/HasReferences.kt View File

@@ -0,0 +1,13 @@
package me.jfenn.ktordocs.`interface`

import me.jfenn.ktordocs.model.ReferenceInfo

interface HasReferences {

val references: ArrayList<ReferenceInfo>

fun reference(url: String, title: String = url) {
references.add(ReferenceInfo(url, title))
}

}

+ 40
- 0
docs/src/me/jfenn/ktordocs/model/AuthenticationInfo.kt View File

@@ -0,0 +1,40 @@
package me.jfenn.ktordocs.model

import me.jfenn.ktordocs.`interface`.HasParams
import me.jfenn.ktordocs.`interface`.HasReferences

data class AuthenticationInfo(
val name: String,
var title: String = name,
var desc: String = "No description provided.",
var subDesc: String? = null,
internal var type: Type = Type.Unknown
) : HasReferences, HasParams {

override var references = arrayListOf(
ReferenceInfo("https://ktor.io/servers/features/authentication", "Ktor Documentation")
)

override val params = HashMap<String, ParameterInfo>()

sealed class Type(val value: String) {
object Unknown : Type("")
object Basic : Type("basic")
object Form : Type("form")
}

fun type(type: Type) {
when (type) {
Type.Basic -> {
subDesc = "The username and password are provided as raw values in a request header."
param("Basic") {
desc = "A header containing the username and password, separated by a colon (':') character."
location = ParameterInfo.In.Header
example = "username:password"
}
}
else -> {}
}
}

}

+ 7
- 1
docs/src/me/jfenn/ktordocs/model/Configuration.kt View File

@@ -7,7 +7,13 @@ class Configuration {
var baseUrl = "http://localhost:8080"

var title = "REST API"
var description = "This documentation describes the website's API endpoints."
var desc = "This documentation describes the website's API endpoints."

internal val authMethods = HashMap<String, AuthenticationInfo>()

fun authMethod(name: String, configure: AuthenticationInfo.() -> Unit) {
authMethods[name] = (authMethods[name] ?: AuthenticationInfo(name)).apply { configure() }
}

internal var defaultEndpoint = EndpointInfo("/", HttpMethod.Get)



+ 16
- 12
docs/src/me/jfenn/ktordocs/model/EndpointInfo.kt View File

@@ -2,46 +2,50 @@ package me.jfenn.ktordocs.model

import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import me.jfenn.ktordocs.`interface`.HasParams
import me.jfenn.ktordocs.`interface`.HasReferences
import me.jfenn.ktordocs.util.slugify

class EndpointInfo(
val path: String,
val method: HttpMethod,
var title: String = path,
var description: String? = null
) {
var desc: String? = null,
var auth: List<String> = listOf()
) : HasReferences, HasParams {

val id get() = title.slugify()

internal val params = HashMap<String, ParameterInfo>()
override var references = ArrayList<ReferenceInfo>()

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

fun responds(code: HttpStatusCode = HttpStatusCode.OK, configure: ResponseInfo.() -> Unit = {}) {
responses[code] = (responses[code] ?: ResponseInfo(code)).apply { configure() }
}

fun copy(
path: String = this.path,
method: HttpMethod = this.method
method: HttpMethod = this.method,
authentication: List<String> = this.auth
) : EndpointInfo {
return EndpointInfo(
path = path,
method = method,
title = title,
description = description
).also {
desc = desc,
auth = authentication
).also { new ->
params.forEach { (name, param) ->
it.params[name] = param.copy()
new.params[name] = param.copy()
}

responses.forEach { (code, response) ->
it.responses[code] = response.copy()
new.responses[code] = response.copy()
}

new.references = ArrayList(references.map { it.copy() })
}
}


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

@@ -2,7 +2,7 @@ package me.jfenn.ktordocs.model

data class ParameterInfo(
val name: String,
var description: String = "No description provided.",
var desc: String = "No description provided.",
var type: String = TYPE_STRING,
var isRequired: Boolean = false,
var location: In = In.Query,


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

@@ -0,0 +1,10 @@
package me.jfenn.ktordocs.model

import io.ktor.http.HttpStatusCode
import kotlin.reflect.KClass
import kotlin.reflect.full.declaredMemberProperties

data class ReferenceInfo(
val url: String,
val title: String = url
)

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

@@ -6,7 +6,7 @@ import kotlin.reflect.full.declaredMemberProperties

data class ResponseInfo(
val code: HttpStatusCode,
var description: String? = null,
var desc: String = "No description provided.",
var example: String? = null
) {



+ 1
- 0
example/build.gradle View File

@@ -15,6 +15,7 @@ sourceSets {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "io.ktor:ktor-server-netty:$ktor_version"
implementation "io.ktor:ktor-auth:$ktor_version"
implementation "ch.qos.logback:logback-classic:$logback_version"
testImplementation "io.ktor:ktor-server-tests:$ktor_version"



+ 41
- 20
example/src/Application.kt View File

@@ -2,15 +2,18 @@ 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
import io.ktor.routing.post
import io.ktor.routing.route
import io.ktor.routing.routing
import io.ktor.auth.Authentication
import io.ktor.auth.UserIdPrincipal
import io.ktor.auth.authenticate
import io.ktor.auth.basic
import me.jfenn.ktordocs.RestDocsFeature
import me.jfenn.ktordocs.docs
import me.jfenn.ktordocs.model.AuthenticationInfo
import me.jfenn.ktordocs.model.ParameterInfo
import me.jfenn.ktordocs.restDocumentation

@@ -19,16 +22,32 @@ 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() {
install(Authentication) {
basic("basicAuth") {
realm = "Ktor Server"
validate { credentials ->
if (credentials.name == credentials.password)
UserIdPrincipal(credentials.name)
else null
}
}
}

install(RestDocsFeature) {
baseUrl = "https://example.com"

title = "Example API"
description = "Basic documentation generated for testing & demo purposes."
desc = "Basic documentation generated for testing & demo purposes."

authMethod("basicAuth") {
type(AuthenticationInfo.Type.Basic)
title = "Basic Authentication"
desc = "A simple authentication method intended for debugging, which only checks whether a username is equal to the provided password."
}

defaultProperties {
param("accept") {
description = "The content type to return - such as 'application/json'"
desc = "The content type to return - such as 'application/json'"
location = ParameterInfo.In.Header
type = "Content Type"
example = "application/json"
@@ -41,7 +60,7 @@ fun Application.module() {
get("/hello") {
docs {
title = "Hello world"
description = "Responds with 'hello world' :)"
desc = "Responds with 'hello world' :)"
}

call.respondText("""{"hello": "world"}""", ContentType.Application.Json)
@@ -50,13 +69,13 @@ fun Application.module() {
get("/user/{username?}") {
docs {
title = "Get user info"
description = "Returns an object representation of the requested user ID."
desc = "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."
desc = "A valid ID or username of the user to fetch."
}
responds(HttpStatusCode.OK) {
description = "If the user has been found"
responds {
desc = "If the user has been found"
exampleJson(UserInfo::class)
}
}
@@ -66,22 +85,24 @@ fun Application.module() {
""".trimIndent())
}

post("/user/{username}") {
docs {
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"
authenticate("basicAuth") {
post("/user/{username}") {
docs {
title = "Modify user info"
desc = "Updates the provided user info in the database."
param("username") {
type = "SHA1 hash"
desc = "A valid ID or username of the user to update."
}
responds {
desc = "If the user was successfully updated"
}
}
}
}
}

restDocumentation("/docs")
restDocumentation("/")
}

}


+ 1
- 1
gradle.properties View File

@@ -1,4 +1,4 @@
ktor_version=1.3.2
kotlin.code.style=official
kotlin_version=1.3.70
kotlin_version=1.3.72
logback_version=1.2.1

Loading…
Cancel
Save