diff --git a/docs/build.gradle b/docs/build.gradle index 39b865d4d12edfd731a3beba917756e6d4d8e181..7e5495a63ca855cd9d10114b59e5eef8498ac572 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -14,5 +14,8 @@ 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" + + implementation "com.atlassian.commonmark:commonmark:0.15.2" } diff --git a/docs/src/me/jfenn/ktordocs/HtmlBuilder.kt b/docs/src/me/jfenn/ktordocs/HtmlBuilder.kt index 094ca2577a1897dd5190f549f1b394350e71b8e6..1c729cb59033230a89d873c02dc1a4f85b330d4b 100644 --- a/docs/src/me/jfenn/ktordocs/HtmlBuilder.kt +++ b/docs/src/me/jfenn/ktordocs/HtmlBuilder.kt @@ -7,6 +7,8 @@ import me.jfenn.ktordocs.`interface`.HasParams import me.jfenn.ktordocs.`interface`.HasReferences import me.jfenn.ktordocs.model.EndpointInfo import me.jfenn.ktordocs.model.ParameterInfo +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer class HtmlBuilder( val docs: RestDocs @@ -48,7 +50,9 @@ class HtmlBuilder( } td("text-muted") { +param.type } td("text-muted") { +param.location.value } - td("text-muted") { +param.desc } + td("text-muted") { + markdown(param.desc, inline = true) + } } } } @@ -80,7 +84,9 @@ class HtmlBuilder( } } endpoint.desc?.let { - p("text-muted") { +it } + div("text-muted") { + markdown(it) + } } div("alert bg-light") { span("mr-3 badge badge-" + when (endpoint.method) { @@ -101,22 +107,18 @@ class HtmlBuilder( parameterInfo(endpoint) h5 { +"Code samples" } - pre("alert bg-light") { - code { - style = "tab-size: 2;" - - +buildString { - appendln("curl -X ${endpoint.method.value} \\") + codeblock(tabSize = 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}' \\") - } + 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}' \\") + if (endpoint.method == HttpMethod.Post || endpoint.method == HttpMethod.Put) + appendln("\t-d '{data}' \\") - appendln("\t${docs.config.baseUrl}${endpoint.path}") - } + appendln("\t${docs.config.baseUrl}${endpoint.path}") } } @@ -139,15 +141,12 @@ class HtmlBuilder( span("text-muted") { +" (${code.description})" } } } - td("text-muted") { +response.desc } + td("text-muted") { + markdown(response.desc, inline = true) + } td { response.example?.let { - pre("alert bg-light") { - code { - style = "tab-size: 4;" - +it - } - } + codeblock { +it } } } } @@ -173,25 +172,29 @@ class HtmlBuilder( ) style { - +"table {font-size:0.8rem;display:inline-block;max-width:100%;overflow-x:auto;}" + +"table {font-size:0.8rem;display:inline-block;max-width:100%;overflow-x:auto;}.bg-dark a {color:#b1d4fb !important;}" } } body { div("row m-0") { div("col-12 col-md-3 bg-dark text-light") { + style = "background-color: #05264c !important; font-size: 0.9rem;" + div("py-4") { style = "position: sticky; top: 0;" div("px-2") { h3 { +docs.config.title } - p("text-white-50") { +docs.config.desc } + div("text-white-50") { + markdown(docs.config.desc) + } - span("font-weight-bold") { +"Contents" } + h5 { +"Contents" } } docs.endpoints.forEach { - div("p-2 border-bottom border-secondary") { - a(classes = "text-light", href = "#" + it.id) { +it.title } + div("p-2 border-bottom border-primary") { + a(href = "#" + it.id) { +it.title } } } } @@ -206,7 +209,7 @@ class HtmlBuilder( docs.config.authMethods.forEach { (name, auth) -> modal("auth_${name}", auth.title) { - p { +auth.desc } + markdown(auth.desc) auth.subDesc?.let { p { small("text-muted") { +it } } @@ -247,3 +250,25 @@ fun FlowContent.modal(modalId: String, modalTitle: String, content: DIV.() -> Un } } } + +fun FlowContent.codeblock(tabSize: Int = 4, content: CODE.() -> Unit) { + pre("alert bg-light") { + code { + style = "tab-size: ${tabSize};" + content() + } + } +} + +fun HTMLTag.markdown(markdown: String, inline: Boolean = false) { + val parser = Parser.builder().build() + val renderer = HtmlRenderer.builder().build() + + val html = renderer.render(parser.parse(markdown)) + + unsafe { + if (inline) + +html.replace(Regex(""), "") + else +html + } +} diff --git a/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt b/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt index 2261959e58db9afcd1f478cb760ed66c05a078a8..5ba91b84ea7ecfb5677f7b6cb2ba2471e62710b2 100644 --- a/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt +++ b/docs/src/me/jfenn/ktordocs/RestDocsFeature.kt @@ -42,7 +42,13 @@ class RestDocsFeature( // add auth methods newEndpoint = inheritEndpoint.copy( authentication = selector.names.filterNotNull() - ) + ).apply { + selector.names.firstOrNull()?.let { + docs.config.authMethods[it] + }?.also { + params.putAll(it.params) + } + } } (route.selector as? HttpMethodRouteSelector)?.let { selector -> diff --git a/docs/src/me/jfenn/ktordocs/model/AuthenticationInfo.kt b/docs/src/me/jfenn/ktordocs/model/AuthenticationInfo.kt index dc0143013b0fc4abd78d18cc6126277a988f1fb4..be02bfcc304062c5711f24e419a8eef832c3ea98 100644 --- a/docs/src/me/jfenn/ktordocs/model/AuthenticationInfo.kt +++ b/docs/src/me/jfenn/ktordocs/model/AuthenticationInfo.kt @@ -20,19 +20,60 @@ data class AuthenticationInfo( sealed class Type(val value: String) { object Unknown : Type("") object Basic : Type("basic") - object Form : Type("form") + class Form( + val userParamName: String = "user", + val passwordParamName: String = "password" + ) : Type("form") + object Digest : Type("digest") + object JWT : Type("jwt") + object OAuth : Type("oauth") } fun type(type: Type) { when (type) { - Type.Basic -> { + is 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." + desc = "A string containing the username and password separated by a colon (':') character." location = ParameterInfo.In.Header example = "username:password" } } + is Type.Form -> { + subDesc = "The username and password are provided as raw values from a form submission." + param(type.userParamName) { + desc = "The user's username / identifier." + location = ParameterInfo.In.FormData + } + param(type.passwordParamName) { + desc = "The user's raw password." + location = ParameterInfo.In.FormData + } + reference("https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects", "Web FormData API") + } + is Type.Digest -> { + subDesc = "The username and password are provided in a `Digest` authorization header." + param("Authorization") { + desc = "A string containing various request authorization properties." + location = ParameterInfo.In.Header + example = """Digest realm="{realm}" username="{username}" uri="{uri}" nonce="{nonce}" opaque="{opaque}" nc="{nc}" algorithm="MD5" response="{response}" cnonce="{cnonce}" qop="{qop}"""" + } + reference("https://en.wikipedia.org/wiki/Digest_access_authentication", "Digest access authentication (Wikipedia)") + } + is Type.JWT -> { + subDesc = "The client provides a stateless JSON Web Token in a `Bearer` authorization header." + param("Authorization") { + desc = "A string containing various request authorization properties." + location = ParameterInfo.In.Header + example = "Bearer {token}" + } + reference("https://jwt.io/", "JWT.io") + } + is Type.OAuth -> { + subDesc = "The client must authenticate with an external OAuth provider." + reference("https://ktor.io/servers/features/authentication/oauth.html", "Ktor OAuth Guide") + reference("https://oauth.net/2/", "OAuth 2.0 Specification") + } else -> {} } } diff --git a/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt b/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt index b5db255d70c4a7ce9ae564ddc7fc69f4f3aab9d8..eca40a52cfbb031fe350ba1ed10902d04a120bf4 100644 --- a/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt +++ b/docs/src/me/jfenn/ktordocs/model/ParameterInfo.kt @@ -13,6 +13,7 @@ data class ParameterInfo( object Header : In("header") object Path : In("path") object Query : In("query") + object FormData : In("FormData") } companion object { diff --git a/example/src/Application.kt b/example/src/Application.kt index 178a0f27aebb1f436ed3facbf2c25f237b7df12d..c0a32536ab09506c3e0b2df00e78b16672841c75 100644 --- a/example/src/Application.kt +++ b/example/src/Application.kt @@ -1,16 +1,13 @@ package me.jfenn import io.ktor.application.* +import io.ktor.auth.* import io.ktor.http.ContentType import io.ktor.response.* 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 @@ -37,17 +34,21 @@ fun Application.module() { baseUrl = "https://example.com" title = "Example API" - desc = "Basic documentation generated for testing & demo purposes." + desc = """ + Basic documentation generated for **testing** and _demo_ purposes. + + Find the source code [here](https://code.horrific.dev/james/ktordocs)! + """.trimIndent() 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." + desc = "A simple authentication method **intended for debugging**, which _only_ checks whether a username is equal to the provided password." } defaultProperties { param("accept") { - desc = "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" @@ -60,7 +61,7 @@ fun Application.module() { get("/hello") { docs { title = "Hello world" - desc = "Responds with 'hello world' :)" + desc = "Responds with _\"hello world\"_ :)" } call.respondText("""{"hello": "world"}""", ContentType.Application.Json)