@@ -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" | |||
} |
@@ -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("</?p>"), "") | |||
else +html | |||
} | |||
} |
@@ -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 -> | |||
@@ -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 -> {} | |||
} | |||
} | |||
@@ -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 { | |||
@@ -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) | |||