@@ -0,0 +1,7 @@ | |||
/.gradle | |||
/.idea | |||
/out | |||
build/ | |||
*.iml | |||
*.ipr | |||
*.iws |
@@ -0,0 +1,30 @@ | |||
// Top-level build file where you can add configuration options common to all sub-projects/modules. | |||
buildscript { | |||
ext.kotlin_version = '1.3.72' | |||
ext.ktor_version = '1.3.2' | |||
ext.serialization_version = '0.20.0' | |||
repositories { | |||
google() | |||
jcenter() | |||
maven { url "https://kotlin.bintray.com/kotlinx" } | |||
maven { url "https://plugins.gradle.org/m2/" } | |||
} | |||
dependencies { | |||
classpath 'com.android.tools.build:gradle:4.0.0' | |||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" | |||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" | |||
// NOTE: Do not place your application dependencies here; they belong | |||
// in the individual module build.gradle files | |||
} | |||
} | |||
allprojects { | |||
repositories { | |||
google() | |||
jcenter() | |||
maven { url 'https://kotlin.bintray.com/ktor' } | |||
} | |||
} |
@@ -0,0 +1,17 @@ | |||
apply plugin: 'kotlin' | |||
group 'me.jfenn' | |||
version '0.0.1' | |||
sourceSets { | |||
main.kotlin.srcDirs = main.java.srcDirs = ['src'] | |||
test.kotlin.srcDirs = test.java.srcDirs = ['test'] | |||
main.resources.srcDirs = ['resources'] | |||
test.resources.srcDirs = ['testresources'] | |||
} | |||
dependencies { | |||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" | |||
implementation "io.ktor:ktor-server-core:$ktor_version" | |||
implementation "io.ktor:ktor-html-builder:$ktor_version" | |||
} |
@@ -0,0 +1,50 @@ | |||
package me.jfenn.ktordocs | |||
import kotlinx.html.* | |||
import kotlinx.html.stream.appendHTML | |||
import me.jfenn.ktordocs.model.Configuration | |||
import me.jfenn.ktordocs.model.EndpointInfo | |||
class RestDocs( | |||
configure: Configuration.() -> Unit | |||
) { | |||
val config = Configuration().apply { configure() } | |||
val endpoints = ArrayList<EndpointInfo>() | |||
fun add(info: EndpointInfo) { | |||
endpoints.add(info) | |||
} | |||
fun FlowContent.endpointInfo(endpoint: EndpointInfo) { | |||
div("card my-3") { | |||
h5("card-header") { +endpoint.path } | |||
div("card-body") { | |||
endpoint.description?.let { | |||
p("card-text") { +it } | |||
} | |||
} | |||
} | |||
} | |||
fun toHtml() : String = buildString { | |||
appendln("<!DOCTYPE html>") | |||
appendHTML().html { | |||
head { | |||
title(config.title) | |||
link( | |||
href = "https://stackpath.bootstrapcdn.com/bootstrap/4.5.1/css/bootstrap.min.css", | |||
rel = "stylesheet" | |||
) | |||
} | |||
body { | |||
div("container") { | |||
endpoints.forEach { endpointInfo(it) } | |||
} | |||
} | |||
} | |||
appendln() | |||
} | |||
} |
@@ -0,0 +1,121 @@ | |||
package me.jfenn.ktordocs | |||
import io.ktor.application.* | |||
import io.ktor.http.ContentType | |||
import io.ktor.response.respondText | |||
import io.ktor.routing.* | |||
import io.ktor.util.AttributeKey | |||
import io.ktor.util.pipeline.PipelineContext | |||
import io.ktor.util.pipeline.PipelineInterceptor | |||
import kotlinx.coroutines.GlobalScope | |||
import kotlinx.coroutines.launch | |||
import me.jfenn.ktordocs.model.Configuration | |||
import me.jfenn.ktordocs.model.EndpointInfo | |||
import kotlin.coroutines.CoroutineContext | |||
import kotlin.reflect.full.memberProperties | |||
class RestDocsFeature( | |||
val docs: RestDocs | |||
) { | |||
private fun buildPathString(selectors: List<RouteSelector>, defaultParam: String? = null) : String { | |||
return "/" + selectors.mapNotNull { | |||
when (it) { | |||
is PathSegmentConstantRouteSelector -> it.value | |||
is PathSegmentParameterRouteSelector -> defaultParam ?: "{${it.name}}" | |||
is PathSegmentOptionalParameterRouteSelector -> defaultParam ?: "{${it.name}?}" | |||
is PathSegmentTailcardRouteSelector -> defaultParam ?: "{...${it.name}}" | |||
is PathSegmentWildcardRouteSelector -> defaultParam ?: "*" | |||
else -> null | |||
} | |||
}.joinToString("/") | |||
} | |||
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") | |||
// using reflection to obtain the "route.handlers" property | |||
val handlers = Route::class.memberProperties.find { | |||
it.name == "handlers" | |||
}?.get(route) as ArrayList<PipelineInterceptor<Unit, ApplicationCall>> | |||
handlers.forEach { | |||
try { // try to invoke each handler | |||
DummyPipelineContext.it(Unit) | |||
} catch (e: DocsProxyException) { | |||
// caught proxy extension; add endpoint to docs | |||
e.configure.invoke(endpoint) | |||
docs.add(endpoint) | |||
return | |||
} catch (e: Throwable) { | |||
// do nothing | |||
} | |||
} | |||
} | |||
route.children.forEach { | |||
updateRoutes(it, selectors + it.selector) | |||
} | |||
} | |||
companion object : ApplicationFeature<Application, Configuration, RestDocsFeature> { | |||
override val key = AttributeKey<RestDocsFeature>("Ktor REST Documentation") | |||
override fun install(pipeline: Application, configure: Configuration.() -> Unit) : RestDocsFeature { | |||
val docs = RestDocs { | |||
configure() | |||
} | |||
return RestDocsFeature(docs) | |||
} | |||
} | |||
} | |||
fun Route.restDocumentation(path: String = "/") { | |||
GlobalScope.launch { | |||
application.feature(RestDocsFeature).updateRoutes(this@restDocumentation) | |||
} | |||
get(path) { | |||
application.feature(RestDocsFeature).apply { | |||
call.respondText(docs.toHtml(), ContentType.Text.Html) | |||
} | |||
} | |||
} | |||
fun PipelineContext<Unit, ApplicationCall>.docs(configure: EndpointInfo.() -> Unit) { | |||
if (this is DummyPipelineContext) | |||
throw DocsProxyException(configure) | |||
} | |||
class DocsProxyException( | |||
val configure: EndpointInfo.() -> Unit | |||
) : RuntimeException("default ktor docs behavior") | |||
object DummyPipelineContext : PipelineContext<Unit, ApplicationCall> { | |||
override val context: ApplicationCall | |||
get() = TODO("Not yet implemented") | |||
override val coroutineContext: CoroutineContext | |||
get() = TODO("Not yet implemented") | |||
override val subject: Unit | |||
get() = TODO("Not yet implemented") | |||
override fun finish() { | |||
TODO("Not yet implemented") | |||
} | |||
override suspend fun proceed() { | |||
TODO("Not yet implemented") | |||
} | |||
override suspend fun proceedWith(subject: Unit) { | |||
TODO("Not yet implemented") | |||
} | |||
} |
@@ -0,0 +1,7 @@ | |||
package me.jfenn.ktordocs.model | |||
class Configuration { | |||
var title = "Ktor REST Documentation" | |||
} |
@@ -0,0 +1,19 @@ | |||
package me.jfenn.ktordocs.model | |||
import io.ktor.http.HttpMethod | |||
class EndpointInfo( | |||
val path: String, | |||
val method: HttpMethod | |||
) { | |||
var description: String? = null | |||
// val params: ArrayList<ParameterInfo> | |||
// val responses: ArrayList<ResponseInfo> | |||
// fun param(...) | |||
// fun responds(...) | |||
} |
@@ -0,0 +1,22 @@ | |||
apply plugin: 'kotlin' | |||
apply plugin: 'application' | |||
group 'me.jfenn' | |||
version '0.0.1' | |||
mainClassName = "io.ktor.server.netty.EngineMain" | |||
sourceSets { | |||
main.kotlin.srcDirs = main.java.srcDirs = ['src'] | |||
test.kotlin.srcDirs = test.java.srcDirs = ['test'] | |||
main.resources.srcDirs = ['resources'] | |||
test.resources.srcDirs = ['testresources'] | |||
} | |||
dependencies { | |||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" | |||
implementation "io.ktor:ktor-server-netty:$ktor_version" | |||
implementation "ch.qos.logback:logback-classic:$logback_version" | |||
testImplementation "io.ktor:ktor-server-tests:$ktor_version" | |||
implementation project(':docs') | |||
} |
@@ -0,0 +1,9 @@ | |||
ktor { | |||
deployment { | |||
port = 8080 | |||
port = ${?PORT} | |||
} | |||
application { | |||
modules = [ me.jfenn.ApplicationKt.module ] | |||
} | |||
} |
@@ -0,0 +1,12 @@ | |||
<configuration> | |||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> | |||
<encoder> | |||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> | |||
</encoder> | |||
</appender> | |||
<root level="trace"> | |||
<appender-ref ref="STDOUT"/> | |||
</root> | |||
<logger name="org.eclipse.jetty" level="INFO"/> | |||
<logger name="io.netty" level="INFO"/> | |||
</configuration> |
@@ -0,0 +1,36 @@ | |||
package me.jfenn | |||
import io.ktor.application.* | |||
import io.ktor.http.ContentType | |||
import io.ktor.response.* | |||
import io.ktor.request.* | |||
import io.ktor.routing.get | |||
import io.ktor.routing.routing | |||
import me.jfenn.ktordocs.RestDocsFeature | |||
import me.jfenn.ktordocs.docs | |||
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) { | |||
install(RestDocsFeature) { | |||
} | |||
routing { | |||
get("/api/hello") { | |||
docs { | |||
description = "Responds with 'hello world' :)" | |||
} | |||
call.respondText("""{"hello": "world"}""", ContentType.Application.Json) | |||
} | |||
restDocumentation("/docs") | |||
} | |||
} | |||
@@ -0,0 +1,4 @@ | |||
ktor_version=1.3.2 | |||
kotlin.code.style=official | |||
kotlin_version=1.3.70 | |||
logback_version=1.2.1 |
@@ -0,0 +1,5 @@ | |||
distributionBase=GRADLE_USER_HOME | |||
distributionPath=wrapper/dists | |||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-bin.zip | |||
zipStoreBase=GRADLE_USER_HOME | |||
zipStorePath=wrapper/dists |
@@ -0,0 +1,188 @@ | |||
#!/usr/bin/env sh | |||
# | |||
# Copyright 2015 the original author or authors. | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); | |||
# you may not use this file except in compliance with the License. | |||
# You may obtain a copy of the License at | |||
# | |||
# https://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, | |||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
# See the License for the specific language governing permissions and | |||
# limitations under the License. | |||
# | |||
############################################################################## | |||
## | |||
## Gradle start up script for UN*X | |||
## | |||
############################################################################## | |||
# Attempt to set APP_HOME | |||
# Resolve links: $0 may be a link | |||
PRG="$0" | |||
# Need this for relative symlinks. | |||
while [ -h "$PRG" ] ; do | |||
ls=`ls -ld "$PRG"` | |||
link=`expr "$ls" : '.*-> \(.*\)$'` | |||
if expr "$link" : '/.*' > /dev/null; then | |||
PRG="$link" | |||
else | |||
PRG=`dirname "$PRG"`"/$link" | |||
fi | |||
done | |||
SAVED="`pwd`" | |||
cd "`dirname \"$PRG\"`/" >/dev/null | |||
APP_HOME="`pwd -P`" | |||
cd "$SAVED" >/dev/null | |||
APP_NAME="Gradle" | |||
APP_BASE_NAME=`basename "$0"` | |||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. | |||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' | |||
# Use the maximum available, or set MAX_FD != -1 to use that value. | |||
MAX_FD="maximum" | |||
warn () { | |||
echo "$*" | |||
} | |||
die () { | |||
echo | |||
echo "$*" | |||
echo | |||
exit 1 | |||
} | |||
# OS specific support (must be 'true' or 'false'). | |||
cygwin=false | |||
msys=false | |||
darwin=false | |||
nonstop=false | |||
case "`uname`" in | |||
CYGWIN* ) | |||
cygwin=true | |||
;; | |||
Darwin* ) | |||
darwin=true | |||
;; | |||
MINGW* ) | |||
msys=true | |||
;; | |||
NONSTOP* ) | |||
nonstop=true | |||
;; | |||
esac | |||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar | |||
# Determine the Java command to use to start the JVM. | |||
if [ -n "$JAVA_HOME" ] ; then | |||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then | |||
# IBM's JDK on AIX uses strange locations for the executables | |||
JAVACMD="$JAVA_HOME/jre/sh/java" | |||
else | |||
JAVACMD="$JAVA_HOME/bin/java" | |||
fi | |||
if [ ! -x "$JAVACMD" ] ; then | |||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME | |||
Please set the JAVA_HOME variable in your environment to match the | |||
location of your Java installation." | |||
fi | |||
else | |||
JAVACMD="java" | |||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. | |||
Please set the JAVA_HOME variable in your environment to match the | |||
location of your Java installation." | |||
fi | |||
# Increase the maximum file descriptors if we can. | |||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then | |||
MAX_FD_LIMIT=`ulimit -H -n` | |||
if [ $? -eq 0 ] ; then | |||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then | |||
MAX_FD="$MAX_FD_LIMIT" | |||
fi | |||
ulimit -n $MAX_FD | |||
if [ $? -ne 0 ] ; then | |||
warn "Could not set maximum file descriptor limit: $MAX_FD" | |||
fi | |||
else | |||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" | |||
fi | |||
fi | |||
# For Darwin, add options to specify how the application appears in the dock | |||
if $darwin; then | |||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" | |||
fi | |||
# For Cygwin or MSYS, switch paths to Windows format before running java | |||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then | |||
APP_HOME=`cygpath --path --mixed "$APP_HOME"` | |||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` | |||
JAVACMD=`cygpath --unix "$JAVACMD"` | |||
# We build the pattern for arguments to be converted via cygpath | |||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` | |||
SEP="" | |||
for dir in $ROOTDIRSRAW ; do | |||
ROOTDIRS="$ROOTDIRS$SEP$dir" | |||
SEP="|" | |||
done | |||
OURCYGPATTERN="(^($ROOTDIRS))" | |||
# Add a user-defined pattern to the cygpath arguments | |||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then | |||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" | |||
fi | |||
# Now convert the arguments - kludge to limit ourselves to /bin/sh | |||
i=0 | |||
for arg in "$@" ; do | |||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` | |||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option | |||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition | |||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` | |||
else | |||
eval `echo args$i`="\"$arg\"" | |||
fi | |||
i=$((i+1)) | |||
done | |||
case $i in | |||
(0) set -- ;; | |||
(1) set -- "$args0" ;; | |||
(2) set -- "$args0" "$args1" ;; | |||
(3) set -- "$args0" "$args1" "$args2" ;; | |||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;; | |||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; | |||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; | |||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; | |||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; | |||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; | |||
esac | |||
fi | |||
# Escape application args | |||
save () { | |||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done | |||
echo " " | |||
} | |||
APP_ARGS=$(save "$@") | |||
# Collect all arguments for the java command, following the shell quoting and substitution rules | |||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" | |||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong | |||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then | |||
cd "$(dirname "$0")" | |||
fi | |||
exec "$JAVACMD" "$@" |
@@ -0,0 +1,100 @@ | |||
@rem | |||
@rem Copyright 2015 the original author or authors. | |||
@rem | |||
@rem Licensed under the Apache License, Version 2.0 (the "License"); | |||
@rem you may not use this file except in compliance with the License. | |||
@rem You may obtain a copy of the License at | |||
@rem | |||
@rem https://www.apache.org/licenses/LICENSE-2.0 | |||
@rem | |||
@rem Unless required by applicable law or agreed to in writing, software | |||
@rem distributed under the License is distributed on an "AS IS" BASIS, | |||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
@rem See the License for the specific language governing permissions and | |||
@rem limitations under the License. | |||
@rem | |||
@if "%DEBUG%" == "" @echo off | |||
@rem ########################################################################## | |||
@rem | |||
@rem Gradle startup script for Windows | |||
@rem | |||
@rem ########################################################################## | |||
@rem Set local scope for the variables with windows NT shell | |||
if "%OS%"=="Windows_NT" setlocal | |||
set DIRNAME=%~dp0 | |||
if "%DIRNAME%" == "" set DIRNAME=. | |||
set APP_BASE_NAME=%~n0 | |||
set APP_HOME=%DIRNAME% | |||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. | |||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" | |||
@rem Find java.exe | |||
if defined JAVA_HOME goto findJavaFromJavaHome | |||
set JAVA_EXE=java.exe | |||
%JAVA_EXE% -version >NUL 2>&1 | |||
if "%ERRORLEVEL%" == "0" goto init | |||
echo. | |||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. | |||
echo. | |||
echo Please set the JAVA_HOME variable in your environment to match the | |||
echo location of your Java installation. | |||
goto fail | |||
:findJavaFromJavaHome | |||
set JAVA_HOME=%JAVA_HOME:"=% | |||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe | |||
if exist "%JAVA_EXE%" goto init | |||
echo. | |||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% | |||
echo. | |||
echo Please set the JAVA_HOME variable in your environment to match the | |||
echo location of your Java installation. | |||
goto fail | |||
:init | |||
@rem Get command-line arguments, handling Windows variants | |||
if not "%OS%" == "Windows_NT" goto win9xME_args | |||
:win9xME_args | |||
@rem Slurp the command line arguments. | |||
set CMD_LINE_ARGS= | |||
set _SKIP=2 | |||
:win9xME_args_slurp | |||
if "x%~1" == "x" goto execute | |||
set CMD_LINE_ARGS=%* | |||
:execute | |||
@rem Setup the command line | |||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar | |||
@rem Execute Gradle | |||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% | |||
:end | |||
@rem End local scope for the variables with windows NT shell | |||
if "%ERRORLEVEL%"=="0" goto mainEnd | |||
:fail | |||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of | |||
rem the _cmd.exe /c_ return code! | |||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 | |||
exit /b 1 | |||
:mainEnd | |||
if "%OS%"=="Windows_NT" endlocal | |||
:omega |
@@ -0,0 +1,4 @@ | |||
rootProject.name = "ktordocs" | |||
enableFeaturePreview('GRADLE_METADATA') | |||
include ':docs' | |||
include ':example' |