diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8d36b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +### Gradle template +.gradle +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c174c53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Icosillion + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5f26d5 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +![Pine Framework](docs/assets/PineBanner.png?raw=true "Pine Framework") + +Version 0.1 + +> Please note that before 1.0 there are no backwards compatibility or stability guarantees. +If you need a stable base it is recommended that you peg your dependencies to a specific release. + +Pine is a [Kotlin](https://kotlinlang.org/) framework for developing interactive web applications and APIs. +It grew out of a desire to develop APIs very quickly in Kotlin without the typical boilerplace that +is prevalent in most web frameworks for the JVM. + +## Quickstart Guide + +To setup a minimal Pine project, you first need to create a new [Kotlin](https://kotlinlang.org/) project with +[Gradle](https://gradle.org/) support. It is easiest to do this through [IntelliJ](https://www.jetbrains.com/idea/)'s +"New Project" wizard. + +Once you have your base Kotlin project setup, add Pine to your Gradle dependencies. You can find these in the +`build.gradle` file in your project root. + +```groovy +repositories { + maven { + url "https://maven.icosillion.com/artifactory/open-source/" + } +} + +dependencies { + compile 'com.icosillion.pine:pine:0.1' +} +``` + +Once Gradle has finished pulling down all of the Pine dependencies, you can add a new package, for example +`com.example.mypineproject`. After this namespace has been created, you can add your main file to it. + +```kotlin +import com.icosillion.pine.Pine +import com.icosillion.pine.annotations.Route +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.responses.modifiers.withText + +class IndexResource { + + @Route("/") + fun root(request: Request, response: Response): Response { + return response.withText("Hello World!") + } +} + +fun main(args: Array) { + val pine = Pine() + + pine.resource(IndexResource()) + + pine.start() +} +``` + +Now you have everything you need to get started on your first Pine project! \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..264bc05 --- /dev/null +++ b/build.gradle @@ -0,0 +1,80 @@ +group 'com.icosillion.pine' +version '0.1' + +buildscript { + ext.kotlin_version = '1.1.2' + repositories { + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0-M3' + } +} + +apply plugin: 'kotlin' +apply plugin: 'java' +apply plugin: 'maven-publish' +apply plugin: 'org.junit.platform.gradle.plugin' + +junitPlatform { + platformVersion '1.0.0-M3' + filters { + engines { + include 'spek' + } + } +} + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() + maven { + url "http://repository.jetbrains.com/all" + } +} + +dependencies { + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" + compile 'org.eclipse.jetty:jetty-server:9.4.4.v20170414' + compile 'org.eclipse.jetty:jetty-servlet:9.4.4.v20170414' + compile 'com.google.code.gson:gson:2.8.0' + compile 'commons-io:commons-io:2.5' + compile 'com.github.salomonbrys.kotson:kotson:2.5.0' + compile 'com.github.spullara.mustache.java:compiler:0.9.4' + compile 'jmimemagic:jmimemagic:0.1.2' + compile 'com.github.salomonbrys.kodein:kodein:3.4.1' + + testCompile 'org.jetbrains.spek:spek-api:1.1.0' + testCompile 'org.jetbrains.spek:spek-junit-platform-engine:1.1.0' + testCompile group: 'junit', name: 'junit', version: '4.12' +} + +sourceSets { + main.java.srcDirs += 'src/main/kotlin' +} + +kapt { + generateStubs = true +} + +publishing { + repositories { + maven { + url 'https://maven.icosillion.com/artifactory/open-source/' + credentials { + username project.properties.containsKey("deploymentUser") ? project.properties.get("deploymentUser") : "" + password project.properties.containsKey("deploymentPassword") ? project.properties.get("deploymentPassword") : "" + } + } + } + + publications { + maven(MavenPublication) { + from components.java + } + } +} diff --git a/docs/assets/PineBanner.png b/docs/assets/PineBanner.png new file mode 100644 index 0000000..012aaa0 Binary files /dev/null and b/docs/assets/PineBanner.png differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..30d399d Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..03af160 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Nov 27 20:28:57 GMT 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.5-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..91a7e26 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# 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 +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# 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\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +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" ] ; 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, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # 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 + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@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 + +@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= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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 Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_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=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +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 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e80b69f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'pine' + diff --git a/src/main/kotlin/com/icosillion/pine/CallableResource.kt b/src/main/kotlin/com/icosillion/pine/CallableResource.kt new file mode 100644 index 0000000..b820be8 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/CallableResource.kt @@ -0,0 +1,23 @@ +package com.icosillion.pine + +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import kotlin.reflect.KCallable + +interface CallableResource { + fun call(request: Request, response: Response) +} + +class ReflectedCallableResource(val obj: Any, val function: KCallable<*>) : CallableResource { + + override fun call(request: Request, response: Response) { + function.call(obj, request, response) + } +} + +class ClosureCallableResource(val function: (Request, Response) -> Response) : CallableResource { + + override fun call(request: Request, response: Response) { + function.invoke(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/Pine.kt b/src/main/kotlin/com/icosillion/pine/Pine.kt new file mode 100644 index 0000000..9ad409c --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/Pine.kt @@ -0,0 +1,284 @@ +package com.icosillion.pine + +import com.github.salomonbrys.kodein.Kodein +import com.icosillion.pine.annotations.Use +import com.icosillion.pine.annotations.Group +import com.icosillion.pine.annotations.Route +import com.icosillion.pine.annotations.UseNamed +import com.icosillion.pine.exceptions.PathParseException +import com.icosillion.pine.handlers.Handler +import com.icosillion.pine.http.Method +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.middleware.Middleware +import com.icosillion.pine.resources.DynamicResource +import com.icosillion.pine.resources.FunctionResource +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import java.net.InetSocketAddress +import java.util.* +import kotlin.reflect.KClass + +/** + * Core Pine Framework + */ +open class Pine(val container: Kodein = Kodein {}) { + + val handlers = arrayListOf() + val resources = arrayListOf() + val bundledMiddleware = hashMapOf>() + private val router = Router() + + /** + * Starts up the HTTP Server + */ + fun start(host: String = "0.0.0.0", port: Int = 8080) { + val server = Server(InetSocketAddress(host, port)); + val handler = ServletContextHandler(server, "/") + handler.addServlet(ServletHolder(PineServlet(this)), "/") + + this.preflight() + + server.start() //TODO catch exceptions from this + + println("Server started at $host:$port") + } + + /** + * This method builds internal route structures from resource types so they can be processed quickly by the server + */ + protected fun preflight() { + //Process Annotations + resources.forEach { resource -> + + if (resource is DynamicResource) { + resource.registerRoutes(this) + } + + val clazz: KClass<*> = resource.javaClass.kotlin + + //Get Group Middleware + val resourceMiddleware = arrayListOf() + clazz.annotations.forEach { annotation -> + when (annotation) { + is Use -> resourceMiddleware.add(annotation.middleware.constructors.first().call()) + is UseNamed -> resourceMiddleware.addAll(bundledMiddleware[annotation.middleware]!!) + } + } + + clazz.members.forEach { function -> + //Get Path Prefix + var pathPrefix = "" + resource.javaClass.kotlin.annotations.forEach { annotation -> + if (annotation is Group) { + pathPrefix = annotation.path + } + } + + //Validate Path Prefix + if (!pathPrefix.isEmpty() && !pathPattern.matcher(pathPrefix).matches()) { + throw PathParseException("Invalid Group Path - \"" + pathPrefix + "\"") + } + + if (!pathPrefix.endsWith('/')) { + pathPrefix += "/"; + } + + function.annotations.forEach { annotation -> + if (annotation is Route) { + //Combine Path + var path = annotation.path + if (path.startsWith('/')) + path = path.substring(1) + + path = pathPrefix + path + + if (path.length > 1 && path.endsWith('/')) + path = path.substring(0, path.length - 1) + + if (!pathPattern.matcher(path).matches()) { + throw PathParseException("Invalid Route Path - \"" + path + "\"") + } + + val middleware = arrayListOf() + + //Add Group Middleware + middleware.addAll(resourceMiddleware) + + //Get Middleware + function.annotations.forEach { annotation -> + when (annotation) { + is Use -> middleware.add(annotation.middleware.constructors.first().call()) + is UseNamed -> middleware.addAll(bundledMiddleware[annotation.middleware]!!) + } + } + + router.register( + path, + annotation.methods, + annotation.accepts, + ReflectedCallableResource(resource, function), + middleware + ) + } + } + } + } + } + + /** + * Processes a request and generates a response for that request + */ + fun handleRequest(request: Request): Response { + return router.route(request) + } + + /** + * Registers a new resource + */ + fun resource(resource: Any) { + resources.add(resource) + } + + /** + * Registers a new handler + */ + fun handler(handler: Handler) { + handlers.add(handler) + } + + /** + * Registers a new route using a function handler + */ + fun function(method: Method, path: String, function: (Request, Response) -> Response) { + this.resource(FunctionResource(method, path, function)) + } + + /** + * Registers a new GET route which will be handled by the passed function + */ + fun get(path: String, function: (Request, Response) -> Response) { + this.function(Method.GET, path, function) + } + + /** + * Registers a new POST route which will be handled by the passed function + */ + fun post(path: String, function: (Request, Response) -> Response) { + this.function(Method.POST, path, function) + } + + /** + * Registers a new POST route which will be handled by the passed function + */ + fun put(path: String, function: (Request, Response) -> Response) { + this.function(Method.PUT, path, function) + } + + /** + * Registers a new PATCH route which will be handled by the passed function + */ + fun patch(path: String, function: (Request, Response) -> Response) { + this.function(Method.PATCH, path, function) + } + + /** + * Registers a new DELETE route which will be handled by the passed function + */ + fun delete(path: String, function: (Request, Response) -> Response) { + this.function(Method.DELETE, path, function) + } + + /** + * Registers a new HEAD route which will be handled by the passed function + */ + fun head(path: String, function: (Request, Response) -> Response) { + this.function(Method.HEAD, path, function) + } + + /** + * Registers a new TRACE route which will be handled by the passed function + */ + fun trace(path: String, function: (Request, Response) -> Response) { + this.function(Method.TRACE, path, function) + } + + /** + * Registers a new OPTIONS route which will be handled by the passed function + */ + fun options(path: String, function: (Request, Response) -> Response) { + this.function(Method.OPTIONS, path, function) + } + + /** + * Registers a new CONNECT route which will be handled by the passed function + */ + fun connect(path: String, function: (Request, Response) -> Response) { + this.function(Method.CONNECT, path, function) + } + + /** + * Registers a new route which will match any verb. This route will be handled by the passed function + */ + fun any(path: String, function: (Request, Response) -> Response) { + this.function(Method.ANY, path, function) + } + + /** + * Registers a new route with the router + */ + fun route(callable: CallableResource, + path: String, + methods: ArrayList = arrayListOf(Method.GET), + accepts: ArrayList = arrayListOf("application/json"), + middleware: ArrayList = arrayListOf() + ) { + //Fetch any Resource-level Middleware + if (callable is ReflectedCallableResource) { + val clazz: KClass<*> = callable.obj.javaClass.kotlin + clazz.annotations.forEach { annotation -> + when (annotation) { + is Use -> middleware.add(annotation.middleware.constructors.first().call()) + is UseNamed -> middleware.addAll(bundledMiddleware[annotation.middleware]!!) + } + } + } + + //Get Path Prefix + if (callable is ReflectedCallableResource) { + var pathPrefix = "" + callable.obj.javaClass.kotlin.annotations.forEach { annotation -> + if (annotation is Group) { + pathPrefix = annotation.path + } + } + + //Validate Path Prefix + if (!pathPrefix.isEmpty() && !pathPattern.matcher(pathPrefix).matches()) { + throw PathParseException("Invalid Group Path - \"" + pathPrefix + "\"") + } + + if (!pathPrefix.endsWith('/')) { + pathPrefix += "/"; + } + } + + //Add route + router.register(path, methods.toTypedArray(), accepts.toTypedArray(), callable, middleware) + } + + /** + * Adds a global named middleware to the stack, which will be applied to all routes + */ + fun namedMiddleware(name: String, middleware: Middleware) { + bundledMiddleware.put(name, arrayOf(middleware)) + } + + /** + * Adds a global middleware bundle to the stack, which will be applied to all routes + */ + fun bundledMiddleware(name: String, middleware: Array) { + bundledMiddleware.put(name, middleware) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/PineServlet.kt b/src/main/kotlin/com/icosillion/pine/PineServlet.kt new file mode 100644 index 0000000..c4e9792 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/PineServlet.kt @@ -0,0 +1,61 @@ +package com.icosillion.pine + +import com.icosillion.pine.http.Method +import com.icosillion.pine.http.Request +import org.apache.commons.io.IOUtils +import java.util.* +import javax.servlet.http.HttpServlet +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * A Servlet which can be loaded into a Java Application Server + */ +class PineServlet(private val pine:Pine) : HttpServlet() { + + override fun service(req: HttpServletRequest?, resp: HttpServletResponse?) { + if(req == null || resp == null) + return + + //Pass to any additional handlers + pine.handlers.forEach { handler -> + //If this request has been handled, finish. + if(handler.handle(req, resp)) { + return@forEach + } + } + + //Parse Query Parameters + val params = hashMapOf() + req.parameterMap.forEach { param -> + params.put(param.key, param.value[0]) + } + + //TODO Handle Proper Encoding + //TODO Handle Max Body Length + val body = IOUtils.toString(req.inputStream) + + val headers = hashMapOf() + req.headerNames.asSequence().forEach { key -> + headers.put(key, req.getHeader(key)) + } + + //Route + val method = Method.fromString(req.method) ?: Method.ANY + val pineRequest = Request(params as Map, body, headers, req.requestURI, method) + + val pineResponse = pine.handleRequest(pineRequest) + + resp.status = pineResponse.status + resp.contentType = pineResponse.headers.getOrElse("Content-Type") { "application/json" } + + //Setup Response Headers + pineResponse.headers.forEach { header -> + resp.addHeader(header.key, header.value) + } + + val writer = resp.writer + writer.write(pineResponse.body.toString()) + writer.close() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/Router.kt b/src/main/kotlin/com/icosillion/pine/Router.kt new file mode 100644 index 0000000..f711ac2 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/Router.kt @@ -0,0 +1,165 @@ +package com.icosillion.pine + +import com.icosillion.pine.annotations.Route +import com.icosillion.pine.responses.JsonProblemResponse +import com.icosillion.pine.http.Method +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.middleware.Middleware +import com.icosillion.pine.middleware.MiddlewareChain +import java.util.regex.Pattern + +val pathPattern = Pattern.compile("(?:/((:[a-zA-Z0-9]+)|\\*|[a-zA-Z0-9\\-\\._~]*))+")!! + +/** + * Provides a structure for describing and matching routes + */ +class RouteDefinition(var path: String, var methods: Array = arrayOf(Method.GET), + var accepts: Array = arrayOf("application/json"), + var middleware: List = arrayListOf()) { + constructor(route: Route) : this(route.path, route.methods, route.accepts) + + private val sections: List + + init { + this.sections = splitPath(this.path) + } + + //TODO Add accept support + /** + * Checks if a given path matches this definition + */ + fun matches(path: String, method: Method): Boolean { + val path = if (path.length > 1 && path.endsWith("/")) path.substring(0, path.length - 1) else path + + val otherSections = splitPath(path) + if (this.sections.count() != otherSections.count()) { + return false; + } + + if (!methods.contains(Method.ANY) && !methods.contains(method)) { + return false; + } + + sections.forEachIndexed { i, section -> + val otherSection = otherSections[i] + if (section != "/*" && !section.startsWith("/:")) { + if (section != otherSection) { + return false + } + } + } + + return true; + } + + /** + * Extracts variables from a given path using the route template + */ + fun getPathParameters(path: String): Map { + val parameters = hashMapOf() + + val dataSections = splitPath(path) + sections.forEachIndexed { i, section -> + if (section.startsWith("/:")) { + val key = section.substring(2) + val value = dataSections[i].substring(1) + parameters.put(key, value) + } + } + + return parameters + } + + /** + * Splits path into sections separated by / + */ + private fun splitPath(path: String): List { + val sections = arrayListOf() + + if (path.length == 0) + return sections + + val pathChars = path.toCharArray() + var sectionContent = "" + pathChars.forEach { c -> + if (c == '/' && sectionContent.isNotEmpty()) { + sections.add(sectionContent) + sectionContent = "/" + } else { + sectionContent += c + } + } + + if (sectionContent.isNotEmpty()) { + sections.add(sectionContent) + } + + return sections + } +} + +/** + * Provides routing and execution for requests + */ +class Router { + + private val routes = hashMapOf() + var errorHandler: (Request, Response, Exception) -> Response = fun (request, response, ex): Response { + println("An exception has been thrown for request '${request.path}'") + ex.printStackTrace() + return JsonProblemResponse(500, "FATAL_EXCEPTION") + } + + var noRouteMatchedHandler: (request: Request) -> Response = fun(request): Response { + return JsonProblemResponse(404, "No Route Matched") + } + + /** + * Registers a new route with the router + */ + fun register(path: String, methods: Array, accepts: Array, callable: CallableResource, + middleware: List = arrayListOf() + ) { + routes.put(RouteDefinition(path, methods, accepts, middleware), callable) + } + + /** + * Routes and executes a request + */ + fun route(request: Request): Response { + var routed = false + var response: Response = Response() + + + routes.forEach routesLoop@ { routeDefinition, callable -> + if (routeDefinition.matches(request.path, request.method)) { + routed = true + + //Inject path parameters + request.pathParameters.putAll(routeDefinition.getPathParameters(request.path)) + + //Call + try { + //Call Before Middleware + response = MiddlewareChain(MiddlewareChain.Type.BEFORE, routeDefinition.middleware).start(request, response) + + //Call Resource + callable.call(request, response) + + //Call After Middleware + response = MiddlewareChain(MiddlewareChain.Type.AFTER, routeDefinition.middleware).start(request, response) + } catch(ex: Exception) { + response = errorHandler(request, response, ex) + return@routesLoop + } + } + } + + if (!routed) { + return noRouteMatchedHandler(request) + } + + return response + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/Test.kt b/src/main/kotlin/com/icosillion/pine/Test.kt new file mode 100644 index 0000000..64cbfaf --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/Test.kt @@ -0,0 +1,80 @@ +package com.icosillion.pine + +import com.github.salomonbrys.kotson.jsonObject +import com.google.gson.JsonObject +import com.icosillion.pine.annotations.Use +import com.icosillion.pine.annotations.Group +import com.icosillion.pine.annotations.Route +import com.icosillion.pine.http.Method +import com.icosillion.pine.responses.JsonProblemResponse +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.middleware.HtmlMiddleware +import com.icosillion.pine.middleware.JsonMiddleware +import com.icosillion.pine.responses.ValidationFailureResponse +import com.icosillion.pine.responses.modifiers.withText +import com.icosillion.pine.validator.Rule +import com.icosillion.pine.validator.objectSchema + +class RootResource { + + @Route("/") + fun root(request: Request, response: Response) { + response.merge(JsonProblemResponse(405, "Method not implemented for this Route")) + } +} + +@Group("/test") +class TestResource { + + val personSchema = objectSchema( + "name" to Rule().string(), + "age" to Rule().integer() + ) + + @Route("/") + fun root(request: Request, response: Response) { + response.merge(JsonProblemResponse(405, "Method not implemented for this Route")) + } + + @Use(HtmlMiddleware::class) + @Route("/html") + fun html(request: Request, response: Response) { + response.body = "Test

Test

" + } + + @Use(JsonMiddleware::class) + @Route("/json") + fun json(request: Request, response: Response) { + response.body = jsonObject( + "test" to "test" + ) + } + + @Use(JsonMiddleware::class) + @Route("/validate", methods = arrayOf(Method.POST)) + fun schemaValidate(request: Request, response: Response) { + val validationResult = personSchema.validateWithReporting(request.body as JsonObject) + if(validationResult.isValid.not()) { + response.merge(ValidationFailureResponse(validationResult)) + return + } + + response.body = jsonObject( + "status" to 200, + "detail" to "Schema Valid!" + ) + } +} + +fun main(args: Array) { + val pine = Pine() + + pine.resource(RootResource()) + pine.resource(TestResource()) + pine.function(Method.GET, "/function", fun (request, response): Response { + return response.withText("This was generated by a function resource") + }) + + pine.start() +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/annotations/Group.kt b/src/main/kotlin/com/icosillion/pine/annotations/Group.kt new file mode 100644 index 0000000..c729f73 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/annotations/Group.kt @@ -0,0 +1,5 @@ +package com.icosillion.pine.annotations + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Group(val path:String) \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/annotations/Route.kt b/src/main/kotlin/com/icosillion/pine/annotations/Route.kt new file mode 100644 index 0000000..bd9bf1d --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/annotations/Route.kt @@ -0,0 +1,8 @@ +package com.icosillion.pine.annotations + +import com.icosillion.pine.http.Method + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Route(val path:String, val methods:Array = arrayOf(Method.GET), + val accepts:Array = arrayOf("application/json")) \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/annotations/Use.kt b/src/main/kotlin/com/icosillion/pine/annotations/Use.kt new file mode 100644 index 0000000..aec77de --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/annotations/Use.kt @@ -0,0 +1,8 @@ +package com.icosillion.pine.annotations + +import com.icosillion.pine.middleware.Middleware +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Use(val middleware:KClass) diff --git a/src/main/kotlin/com/icosillion/pine/annotations/UseNamed.kt b/src/main/kotlin/com/icosillion/pine/annotations/UseNamed.kt new file mode 100644 index 0000000..5fd226b --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/annotations/UseNamed.kt @@ -0,0 +1,5 @@ +package com.icosillion.pine.annotations + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class UseNamed(val middleware:String) diff --git a/src/main/kotlin/com/icosillion/pine/exceptions/PathParseException.kt b/src/main/kotlin/com/icosillion/pine/exceptions/PathParseException.kt new file mode 100644 index 0000000..7601c9d --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/exceptions/PathParseException.kt @@ -0,0 +1,4 @@ +package com.icosillion.pine.exceptions + +class PathParseException(message: String?) : Exception(message) { +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/handlers/Handler.kt b/src/main/kotlin/com/icosillion/pine/handlers/Handler.kt new file mode 100644 index 0000000..e68bff0 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/handlers/Handler.kt @@ -0,0 +1,12 @@ +package com.icosillion.pine.handlers + +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * A handler is a lower-level way to handle requests. They can be used alongside the regular routing framework. + * The primary use for handlers are things that need to be streamed to the client, such as serving assets. + */ +interface Handler { + fun handle(request:HttpServletRequest, response: HttpServletResponse):Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/handlers/StaticFileHandler.kt b/src/main/kotlin/com/icosillion/pine/handlers/StaticFileHandler.kt new file mode 100644 index 0000000..da7f1b7 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/handlers/StaticFileHandler.kt @@ -0,0 +1,71 @@ +package com.icosillion.pine.handlers + +import com.icosillion.pine.RouteDefinition +import com.icosillion.pine.http.Method +import net.sf.jmimemagic.Magic +import net.sf.jmimemagic.MagicMatchNotFoundException +import org.apache.commons.io.IOUtils +import java.io.File +import java.io.FileReader +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * A Handler that can stream files to clients + */ +class StaticFileHandler(var endpoint:String, var directory:String) : Handler { + + init { + if(!endpoint.endsWith("/")) { + endpoint += "/" + } + + if(directory.isEmpty()) { + directory = File("").absolutePath + } + + if(!directory.startsWith("/")) { + directory = File("").absolutePath + "/" + directory + } + + if(!directory.endsWith("/")) { + directory += "/" + } + } + + override fun handle(request: HttpServletRequest, response: HttpServletResponse): Boolean { + + val route = RouteDefinition(endpoint + "*", arrayOf(Method.GET), arrayOf("*")) + val method = Method.fromString(request.method) ?: Method.GET + + if(route.matches(request.requestURI, method)) { + val path = request.requestURI.substring(endpoint.length) + val file = File(directory + path) + val absPath = file.absolutePath + + //Funny stuff is happening, abort! + if(!absPath.startsWith(directory)) + return false + + //Check file exists + if(!file.exists()) + return false + + //Setup MIME Type + try { + response.contentType = Magic.getMagicMatch(file, true).mimeType + } catch(ex:MagicMatchNotFoundException) { + response.contentType = "application/octet-stream" + } + + //Copy file to output + FileReader(file).use { reader -> + IOUtils.copy(reader, response.outputStream) + } + + return true + } + + return false + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/helpers/Json.kt b/src/main/kotlin/com/icosillion/pine/helpers/Json.kt new file mode 100644 index 0000000..82a5e97 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/helpers/Json.kt @@ -0,0 +1,34 @@ +package com.icosillion.pine.helpers + +import com.google.gson.Gson + +private val gson = Gson() + +/** + * Simple GSON wrapper to provide easy JSON operations + */ +class Json { + companion object { + + /** + * Encodes an object to a JSON String + */ + fun encode(obj:Any): String { + return gson.toJson(obj) + } + + /** + * Decodes a JSON Object String to a Map + */ + fun decodeObject(json:String): Map { + return gson.fromJson(json, Map::class.java) as Map + } + + /** + * Decodes a JSON Array String to a List + */ + fun decodeArray(json:String): List { + return gson.fromJson(json, List::class.java) as List + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/helpers/JsonSerializable.kt b/src/main/kotlin/com/icosillion/pine/helpers/JsonSerializable.kt new file mode 100644 index 0000000..0e2f738 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/helpers/JsonSerializable.kt @@ -0,0 +1,11 @@ +package com.icosillion.pine.helpers + +import com.google.gson.JsonElement + +/** + * Provides an interface that classes can use to declare themselves serializable to JSON + */ +interface JsonSerializable { + + fun jsonSerialize(): JsonElement +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/http/Method.kt b/src/main/kotlin/com/icosillion/pine/http/Method.kt new file mode 100644 index 0000000..eb91a6e --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/http/Method.kt @@ -0,0 +1,21 @@ +package com.icosillion.pine.http + +/** + * Models HTTP methods + */ +enum class Method { + + ANY, GET, POST, PUT, DELETE, HEAD, TRACE, OPTIONS, CONNECT, PATCH; + + companion object Factory { + fun fromString(strMethod:String):Method? { + Method.values().forEach { method -> + if(method.toString().equals(strMethod, true)) { + return method; + } + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/http/Request.kt b/src/main/kotlin/com/icosillion/pine/http/Request.kt new file mode 100644 index 0000000..7a8e72d --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/http/Request.kt @@ -0,0 +1,12 @@ +package com.icosillion.pine.http + +import java.util.* + +/** + * Models an HTTP Request + */ +data class Request(var queryParameters:Map, var body:Any, var headers:Map, + var path:String, var method:Method, + var pathParameters: HashMap = hashMapOf()) { + var storage = hashMapOf() +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/http/Response.kt b/src/main/kotlin/com/icosillion/pine/http/Response.kt new file mode 100644 index 0000000..8ea2e2a --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/http/Response.kt @@ -0,0 +1,32 @@ +package com.icosillion.pine.http + +import java.util.* + +/** + * Models an HTTP Response + */ +open class Response(var headers:HashMap = hashMapOf(), + var status:Int = 200, + var body:Any = "") +{ + + var storage = hashMapOf() + + fun merge(response: Response) { + headers.putAll(response.headers) + status = response.status + body = response.body + } + + fun withHeader(key: String, value: String): Response { + headers.put(key, value) + + return this + } + + fun withStatus(status: Int): Response { + this.status = status + + return this + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/middleware/BasicAuthMiddleware.kt b/src/main/kotlin/com/icosillion/pine/middleware/BasicAuthMiddleware.kt new file mode 100644 index 0000000..c3a8d4f --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/middleware/BasicAuthMiddleware.kt @@ -0,0 +1,64 @@ +package com.icosillion.pine.middleware + +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.responses.JsonProblemResponse +import java.util.* + +/** + * Provides HTTP Basic Authentication + */ +class BasicAuthMiddleware( + val realm: String = "API", + private val authFunction: (username: String, password: String) -> Boolean +) : Middleware { + + override fun before(request: Request, response: Response, next: MiddlewareChain): Response { + if(request.headers.containsKey("Authorization")) { + var authHeader = request.headers["Authorization"]!! + if(!authHeader.startsWith("Basic ")) { + return notAuthedResponse(response) + } + + authHeader = authHeader.substring("Basic ".length) + authHeader = authHeader.trim() + + //Decode + authHeader = String(Base64.getDecoder().decode(authHeader)) + + //Split + val authBits = authHeader.split(":") + if(authBits.size != 2) { + notAuthedResponse(response) + } + + val username = authBits[0] + val password = authBits[1] + + //Pass through auth function + if(!authFunction(username, password)) { + return notAuthedResponse(response) + } + + //Set Request Storage Entries + request.storage["auth:username"] = username + request.storage["auth:password"] = password + } else { + return notAuthedResponse(response) + } + + return next(request, response) + } + + override fun after(request: Request, response: Response, next: MiddlewareChain): Response { + return next(request, response) + } + + private fun notAuthedResponse(response: Response): Response { + response.headers["WWW-Authenticate"] = "Basic realm=\"$realm\"" + response.merge(JsonProblemResponse(401, "Unauthorized")) + + return response + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/middleware/HtmlMiddleware.kt b/src/main/kotlin/com/icosillion/pine/middleware/HtmlMiddleware.kt new file mode 100644 index 0000000..7b9998b --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/middleware/HtmlMiddleware.kt @@ -0,0 +1,19 @@ +package com.icosillion.pine.middleware + +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response + +/** + * Provides a very simple HTML middleware layer + */ +class HtmlMiddleware : Middleware { + override fun before(request: Request, response: Response, next: MiddlewareChain): Response { + response.headers.putIfAbsent("Content-Type", "text/html") + + return next(request, response) + } + + override fun after(request: Request, response: Response, next: MiddlewareChain): Response { + return next(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/middleware/JsonMiddleware.kt b/src/main/kotlin/com/icosillion/pine/middleware/JsonMiddleware.kt new file mode 100644 index 0000000..4d554d1 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/middleware/JsonMiddleware.kt @@ -0,0 +1,46 @@ +package com.icosillion.pine.middleware + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.responses.JsonProblemResponse + +/** + * Parses incoming JSON data and encodes outgoing JSON + */ +class JsonMiddleware : Middleware { + + override fun before(request: Request, response: Response, next: MiddlewareChain): Response { + response.headers.putIfAbsent("Content-Type", "application/json") + + if(request.headers.containsKey("Content-Type") + && request.headers["Content-Type"].equals("application/json") + && request.body is String + ) { + try { + val parser = JsonParser() + request.body = parser.parse(request.body as String) + } catch(ex: JsonParseException) { + response.merge(JsonProblemResponse(400, "Invalid Json Body")) + return response + } + } + + return next(request, response) + } + + override fun after(request: Request, response: Response, next: MiddlewareChain): Response { + if(response.body is JsonObject) { + val jsonObject = response.body as JsonObject + response.body = jsonObject.toString() + } else if(response.body is JsonArray) { + val jsonArray = response.body as JsonArray + response.body = jsonArray.toString() + } + + return next(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/middleware/Middleware.kt b/src/main/kotlin/com/icosillion/pine/middleware/Middleware.kt new file mode 100644 index 0000000..f44f50b --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/middleware/Middleware.kt @@ -0,0 +1,57 @@ +package com.icosillion.pine.middleware + +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response + +/** + * Interface for Pine Middleware + */ +interface Middleware { + fun before(request: Request, response: Response, next: MiddlewareChain): Response + fun after(request: Request, response: Response, next: MiddlewareChain): Response +} + +/** + * Helper class for sequential middleware execution + */ +class MiddlewareChain(val type: Type, val middlewares: List) { + + enum class Type { + BEFORE, AFTER + } + + private val iterator: Iterator + + init { + this.iterator = middlewares.iterator() + } + + /** + * Executes the next middleware item in the chain + */ + fun next(request: Request, response: Response): Response { + if (this.iterator.hasNext()) { + if (type == Type.BEFORE) { + return this.iterator.next().before(request, response, this) + } else { + return this.iterator.next().after(request, response, this) + } + } + + return response + } + + /** + * Starts the execution of a middleware chain + */ + fun start(request: Request, response: Response): Response { + return this.next(request, response) + } + + /** + * Enables execution of this object as an alias to the next command + */ + operator fun invoke(request: Request, response: Response): Response { + return this.next(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/resources/DynamicResource.kt b/src/main/kotlin/com/icosillion/pine/resources/DynamicResource.kt new file mode 100644 index 0000000..924e927 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/resources/DynamicResource.kt @@ -0,0 +1,12 @@ +package com.icosillion.pine.resources + +import com.icosillion.pine.Pine + +/** + * Allows resources to register routes dynamically + */ +interface DynamicResource { + + fun registerRoutes(pine: Pine) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/resources/FunctionResource.kt b/src/main/kotlin/com/icosillion/pine/resources/FunctionResource.kt new file mode 100644 index 0000000..084cdfd --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/resources/FunctionResource.kt @@ -0,0 +1,17 @@ +package com.icosillion.pine.resources + +import com.icosillion.pine.ClosureCallableResource +import com.icosillion.pine.Pine +import com.icosillion.pine.http.Method +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response + +/** + * Provides a wrapper around the handler functions so that they can be easily registered in the router + */ +class FunctionResource(private val method: Method, private val path: String, private val function: (Request, Response) -> Response) : DynamicResource { + + override fun registerRoutes(pine: Pine) { + pine.route(ClosureCallableResource(function), path, arrayListOf(method)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/JsonProblemResponse.kt b/src/main/kotlin/com/icosillion/pine/responses/JsonProblemResponse.kt new file mode 100644 index 0000000..1560d1b --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/JsonProblemResponse.kt @@ -0,0 +1,57 @@ +package com.icosillion.pine.responses + +import com.github.salomonbrys.kotson.jsonObject +import com.icosillion.pine.http.Response + +val titles = mapOf( + //400 Client Errors + Pair(400, "Bad Request"), + Pair(401, "Unauthorized"), + Pair(402, "Payment Required"), + Pair(403, "Forbidden"), + Pair(404, "Not Found"), + Pair(405, "Method Not Allowed"), + Pair(406, "Not Acceptable"), + Pair(407, "Proxy Authentication Required"), + Pair(408, "Request Timeout"), + Pair(409, "Conflict"), + Pair(410, "Gone"), + Pair(411, "Length Required"), + Pair(412, "Precondition Failed"), + Pair(413, "Payload Too Large"), + Pair(414, "URI Too Long"), + Pair(415, "Unsupported Media Type"), + Pair(416, "Range Not Satisfiable"), + Pair(417, "Expectation Failed"), + Pair(418, "I'm a Teapot"), + Pair(421, "Misdirected Response"), + Pair(426, "Upgrade Required"), + Pair(428, "Precondition Required"), + Pair(429, "Too Many Requests"), + Pair(431, "Request Header Fields Too Large"), + Pair(451, "Unavailable For Legal Reasons"), + //500 Server Errors + Pair(500, "Internal Server Error"), + Pair(501, "Not Implemented"), + Pair(502, "Bad Gateway"), + Pair(503, "Service Unavailable"), + Pair(504, "Gateway Timeout"), + Pair(505, "HTTP Version Not Supported"), + Pair(506, "Variant Also Negotiates"), + Pair(510, "Not Extended"), + Pair(511, "Network Authentication Required") +) + +class JsonProblemResponse(status:Int, detail:String) + : Response(hashMapOf(Pair("Content-Type", "application/problem+json")), status) { + init { + val title = titles.getOrElse(status) { "Unknown" } + + this.body = jsonObject( + "type" to "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html", + "status" to status, + "title" to title, + "detail" to detail + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/JsonResponse.kt b/src/main/kotlin/com/icosillion/pine/responses/JsonResponse.kt new file mode 100644 index 0000000..42d4226 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/JsonResponse.kt @@ -0,0 +1,20 @@ +package com.icosillion.pine.responses + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.icosillion.pine.http.Response + +/** + * Simple JSON Response + */ +class JsonResponse(status:Int = 200) + : Response(hashMapOf(Pair("Content-Type", "application/json")), status, "{}") { + + constructor(jsonObject: JsonObject, status:Int = 200) : this(status) { + this.body = jsonObject.toString() + } + + constructor(jsonArray: JsonArray, status:Int = 200) : this(status) { + this.body = jsonArray.toString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/TextResponse.kt b/src/main/kotlin/com/icosillion/pine/responses/TextResponse.kt new file mode 100644 index 0000000..fa10a7f --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/TextResponse.kt @@ -0,0 +1,9 @@ +package com.icosillion.pine.responses + +import com.icosillion.pine.http.Response + +/** + * Simple Text Response + */ +class TextResponse(val content: String) + : Response(hashMapOf(Pair("Content-Type", "text/plain")), 200, content) \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/ValidationFailureResponse.kt b/src/main/kotlin/com/icosillion/pine/responses/ValidationFailureResponse.kt new file mode 100644 index 0000000..1a44e75 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/ValidationFailureResponse.kt @@ -0,0 +1,27 @@ +package com.icosillion.pine.responses + +import com.github.salomonbrys.kotson.jsonArray +import com.github.salomonbrys.kotson.jsonObject +import com.google.gson.JsonObject +import com.icosillion.pine.http.Response +import com.icosillion.pine.validator.ValidationResult + +/** + * Provides a simple JSON Problem response for validation failures + */ +class ValidationFailureResponse(validationResult: ValidationResult, status: Int = 400) +: Response(hashMapOf(Pair("Content-Type", "application/problem+json")), status) { + init { + //TODO Ensure validation result has failed + this.merge(JsonProblemResponse(400, "A schema has failed to pass validation")) + val jsonBody = this.body as JsonObject + val jsonErrors = jsonArray() + validationResult.errors.forEach { error -> + jsonErrors.add(jsonObject( + "field" to error.field, + "reason" to error.reason + )) + } + jsonBody.add("errors", jsonErrors) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithHtml.kt b/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithHtml.kt new file mode 100644 index 0000000..b537c4a --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithHtml.kt @@ -0,0 +1,14 @@ +package com.icosillion.pine.responses.modifiers + +import com.icosillion.pine.http.Response + +/** + * Writes HTML to the body and sets the Content-Type header to text/html + */ +fun Response.withHtml(html: String): Response { + this.body = html + + this.headers["Content-Type"] = "text/html" + + return this +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithJson.kt b/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithJson.kt new file mode 100644 index 0000000..0fc29f7 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithJson.kt @@ -0,0 +1,26 @@ +package com.icosillion.pine.responses.modifiers + +import com.google.gson.JsonElement +import com.icosillion.pine.http.Response + +/** + * Encodes and writes JSON to the body and sets the Content-Type header to application/json + */ +fun Response.withJson(json: JsonElement): Response { + + this.headers["Content-Type"] = "application/json" + this.body = json.toString() + + return this +} + +/** + * Writes JSON to the body and sets the Content-Type header to application/json + */ +fun Response.withJson(json: String): Response { + + this.headers["Content-Type"] = "application/json" + this.body = json + + return this +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithText.kt b/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithText.kt new file mode 100644 index 0000000..71b4f68 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/responses/modifiers/WithText.kt @@ -0,0 +1,14 @@ +package com.icosillion.pine.responses.modifiers + +import com.icosillion.pine.http.Response + +/** + * Writes text to the body and sets Content-Type to text/plain + */ +fun Response.withText(text: String): Response { + this.body = text + + this.headers["Content-Type"] = "text/plain" + + return this +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/validator/ArraySchema.kt b/src/main/kotlin/com/icosillion/pine/validator/ArraySchema.kt new file mode 100644 index 0000000..e0cdaa3 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/validator/ArraySchema.kt @@ -0,0 +1,87 @@ +package com.icosillion.pine.validator + +import com.google.gson.JsonArray + +class ArraySchema(val schema: Any) { + + fun validateWithReporting(array: JsonArray): ValidationResult { + var isValid = true + val errors = arrayListOf() + + when(schema) { + is ObjectSchema -> { + array.forEachIndexed { i, value -> + if(value.isJsonObject.not()) { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Element $i is not a JSON Object" + )) + return@forEachIndexed + } + + val result = schema.validateWithReporting(value.asJsonObject) + if(result.isValid.not()) { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Element $i failed to pass object schema" + )) + errors.addAll(result.errors) + return@forEachIndexed + } + } + } + is ArraySchema -> { + array.forEachIndexed { i, value -> + if(value.isJsonArray.not()) { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Element $i is not an array" + )) + return@forEachIndexed + } + + val result = schema.validateWithReporting(value.asJsonArray) + if(result.isValid.not()) { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Element $i failed to pass array schema" + )) + errors.addAll(result.errors) + return@forEachIndexed + } + } + } + is Rule -> { + array.forEachIndexed { i, value -> + val result = schema.validateWithReporting(value) + if(result.isValid.not()) { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Element $i failed to pass rule" + )) + errors.addAll(result.errors) + return@forEachIndexed + } + } + } + else -> { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Invalid data type passed to array schema" + )) + } + } + + return ValidationResult(isValid, errors) + } + + fun validate(array: JsonArray): Boolean { + return validateWithReporting(array).isValid + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/validator/ObjectSchema.kt b/src/main/kotlin/com/icosillion/pine/validator/ObjectSchema.kt new file mode 100644 index 0000000..3b3b9a7 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/validator/ObjectSchema.kt @@ -0,0 +1,77 @@ +package com.icosillion.pine.validator + +import com.google.gson.JsonArray +import com.google.gson.JsonObject + +class ObjectSchema(val rules: List>) { + + fun validateWithReporting(obj: JsonObject): ValidationResult { + var isValid = true + val errors = arrayListOf() + + rules.forEach { pair -> + val key = pair.first + val value = pair.second + + var subObj = obj[key] + + when(value) { + is Rule -> { + val result = value.validateWithReporting(subObj) + if(result.isValid.not()) { + isValid = false + errors.add(ValidationError( + field = key, + reason = result.errors.first().reason + )) + } + } + is ObjectSchema -> { + if(subObj is JsonObject) { + val result = value.validateWithReporting(subObj.asJsonObject) + + if(result.isValid.not()) { + isValid = false + errors.add(ValidationError( + field = key, + reason = result.errors.first().reason + )) + } + } else { + isValid = false + errors.add(ValidationError( + field = key, + reason = "Expected object" + )) + } + } + is ArraySchema -> { + if(subObj is JsonArray) { + /* + if(value.validate(subObj.asJsonArray).not()) { + isValid = false + } + */ + val result = value.validateWithReporting(subObj.asJsonArray) + if(result.isValid.not()) { + isValid = false + errors.add(ValidationError( + field = key, + reason = result.errors.first().reason + )) + } + } + } + else -> { + isValid = false + } + } + } + + return ValidationResult(isValid, errors) + } + + fun validate(obj: JsonObject): Boolean { + return validateWithReporting(obj).isValid + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/validator/Rule.kt b/src/main/kotlin/com/icosillion/pine/validator/Rule.kt new file mode 100644 index 0000000..dfe8897 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/validator/Rule.kt @@ -0,0 +1,266 @@ +package com.icosillion.pine.validator + +import com.google.gson.JsonElement + +class Rule { + + protected var optional = false + protected var nullable = false + private val rules = hashMapOf Boolean>() + + //Rules + fun string(): Rule { + rules.put("string") { value -> + if(value is JsonElement) { + if(value.isJsonPrimitive) { + return@put value.asJsonPrimitive.isString + } + } + + return@put value is String + } + + return this + } + + fun integer(): Rule { + rules.put("integer") { value -> + if(value is JsonElement) { + if(value.isJsonPrimitive) { + if(value.asJsonPrimitive.isNumber) { + return@put value.asJsonPrimitive.asNumber.toDouble().mod(1) == 0.0 + } + } + } + + return@put value is Int + } + + return this + } + + fun optional(): Rule { + optional = true + + return this + } + + fun nullable(): Rule { + nullable = true + + return this + } + + fun min(min: Number): Rule { + rules.put("min") { value -> + + val numberValue = valueAsNumber(value) + val stringValue = valueAsString(value) + + if(numberValue != null) { + if(numberValue.toDouble() < min.toDouble()) { + return@put false + } + + return@put true + } + + if(stringValue != null) { + if(stringValue.length < min.toInt()) { + return@put false + } + + return@put true + } + + return@put false + } + + return this + } + + fun max(max: Number): Rule { + rules.put("max") { value -> + + val numberValue = valueAsNumber(value) + val stringValue = valueAsString(value) + + if(numberValue != null) { + if(numberValue.toDouble() > max.toDouble()) { + return@put false + } + + return@put true + } + + if(stringValue != null) { + if(stringValue.length > max.toInt()) { + return@put false + } + + return@put true + } + + return@put false + } + + return this + } + + fun boolean(): Rule { + rules.put("boolean") { value -> + + val boolValue = valueAsBoolean(value) + return@put boolValue != null + } + + return this + } + + fun number(): Rule { + rules.put("number") { value -> + + val numberValue = valueAsNumber(value) + return@put numberValue != null + } + + return this + } + + fun length(length: Int): Rule { + rules.put("length") { value -> + val stringValue = valueAsString(value) + if(stringValue == null) { + return@put false + } + + return@put stringValue.length == length + } + + return this + } + + fun anyOf(items: Array, ignoreCase: Boolean = true): Rule { + rules.put("anyOf") { value -> + val stringValue = valueAsString(value) + if(stringValue == null) { + return@put false + } + + items.forEach { item -> + if(stringValue.equals(stringValue, ignoreCase)) { + return@put true + } + } + + return@put false + } + + return this + } + + fun validateWithReporting(obj: JsonElement?): ValidationResult { + if(obj == null) { + if(optional) { + return ValidationResult(true) + } else { + return ValidationResult(false, arrayListOf(ValidationError( + field = null, + reason = "Json Element not passed" + ))) + } + } + + if(obj.isJsonNull) { + if(nullable) { + return ValidationResult(true) + } else { + return ValidationResult(false, arrayListOf(ValidationError( + field = null, + reason = "Element is not nullable" + ))) + } + } + + var isValid = true + val errors = arrayListOf() + rules.forEach { type, action -> + if(action.invoke(obj).not()) { + isValid = false + errors.add(ValidationError( + field = null, + reason = "Rule $type failed" + )) + return@forEach + } + } + + return ValidationResult(isValid, errors) + } + + fun validate(obj: JsonElement?): Boolean { + return validateWithReporting(obj).isValid + } + + //Helper Methods + private fun valueAsInteger(value: Any): Int? { + if(value is Int) { + return value + } + + if(value is JsonElement + && value.isJsonPrimitive + && value.asJsonPrimitive.isNumber + && value.asJsonPrimitive.asNumber is Int + ) { + return value.asJsonPrimitive.asInt + } + + return null + } + + private fun valueAsNumber(value: Any): Number? { + if(value is Number) { + return value + } + + if(value is JsonElement + && value.isJsonPrimitive + && value.asJsonPrimitive.isNumber + ) { + return value.asJsonPrimitive.asNumber + } + + return null + } + + private fun valueAsString(value: Any): String? { + if(value is String) { + return value + } + + if(value is JsonElement + && value.isJsonPrimitive + && value.asJsonPrimitive.isString + ) { + return value.asJsonPrimitive.asString + } + + return null + } + + private fun valueAsBoolean(value: Any): Boolean? { + if(value is Boolean) { + return value + } + + if(value is JsonElement + && value.isJsonPrimitive + && value.asJsonPrimitive.isBoolean + ) { + return value.asJsonPrimitive.asBoolean + } + + return null + } +} diff --git a/src/main/kotlin/com/icosillion/pine/validator/ValidationResult.kt b/src/main/kotlin/com/icosillion/pine/validator/ValidationResult.kt new file mode 100644 index 0000000..d3ab9f8 --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/validator/ValidationResult.kt @@ -0,0 +1,10 @@ +package com.icosillion.pine.validator + +import java.util.* + +data class ValidationError(val field: String?, + val reason: String) {} + +data class ValidationResult(var isValid: Boolean, + val errors: ArrayList = arrayListOf()) { +} \ No newline at end of file diff --git a/src/main/kotlin/com/icosillion/pine/validator/Validator.kt b/src/main/kotlin/com/icosillion/pine/validator/Validator.kt new file mode 100644 index 0000000..1311e2c --- /dev/null +++ b/src/main/kotlin/com/icosillion/pine/validator/Validator.kt @@ -0,0 +1,13 @@ +package com.icosillion.pine.validator + +fun objectSchema(vararg values: Pair): ObjectSchema { + return ObjectSchema(values.asList()) +} + +fun arraySchema(schema: ObjectSchema): ArraySchema { + return ArraySchema(schema) +} + +fun arraySchema(rule: Rule): ArraySchema { + return ArraySchema(rule) +} \ No newline at end of file diff --git a/src/test/kotlin/com/icosillion/pine/test/BundledMiddlewareTest.kt b/src/test/kotlin/com/icosillion/pine/test/BundledMiddlewareTest.kt new file mode 100644 index 0000000..2dde317 --- /dev/null +++ b/src/test/kotlin/com/icosillion/pine/test/BundledMiddlewareTest.kt @@ -0,0 +1,63 @@ +package com.icosillion.pine.test + +import com.icosillion.pine.Pine +import com.icosillion.pine.annotations.Route +import com.icosillion.pine.annotations.UseNamed +import com.icosillion.pine.http.Method +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.middleware.HtmlMiddleware +import com.icosillion.pine.middleware.Middleware +import com.icosillion.pine.middleware.MiddlewareChain +import com.icosillion.pine.responses.modifiers.withText +import com.icosillion.pine.test.harness.TestablePine +import org.jetbrains.spek.api.Spek +import org.jetbrains.spek.api.dsl.* +import org.junit.Assert.* + +class CatsMiddleware : Middleware { + + override fun before(request: Request, response: Response, next: MiddlewareChain): Response { + return next(request, response) + } + + override fun after(request: Request, response: Response, next: MiddlewareChain): Response { + response.body = response.body as String + " with cats!" + + return next(request, response) + } +} + +class TestResource { + + @Route("/") + @UseNamed("test") + fun handle(request: Request, response: Response): Response { + response.withText("Hello world") + + return response + } +} + +private fun setup(): Pine { + val pine = TestablePine() + pine.bundledMiddleware("test", arrayOf(HtmlMiddleware(), CatsMiddleware())) + pine.resource(TestResource()) + + pine.testingPreflight() + + return pine +} + +class BundledMiddlewareTest : Spek({ + describe("A simple middleware bundle") { + val pine = setup() + + it("should apply a middleware bundle to a response") { + val response = pine.handleRequest(Request(mapOf(), "", mapOf(), "/", Method.GET)) + + assertEquals("Hello world with cats!", response.body) + assertEquals("text/plain", response.headers["Content-Type"]) + } + } +}) \ No newline at end of file diff --git a/src/test/kotlin/com/icosillion/pine/test/FunctionResourceTest.kt b/src/test/kotlin/com/icosillion/pine/test/FunctionResourceTest.kt new file mode 100644 index 0000000..a2f8848 --- /dev/null +++ b/src/test/kotlin/com/icosillion/pine/test/FunctionResourceTest.kt @@ -0,0 +1,44 @@ +package com.icosillion.pine.test + +import com.icosillion.pine.Pine +import com.icosillion.pine.http.Method +import com.icosillion.pine.http.Request +import com.icosillion.pine.http.Response +import com.icosillion.pine.responses.modifiers.withText +import com.icosillion.pine.test.harness.TestablePine +import org.jetbrains.spek.api.Spek +import org.jetbrains.spek.api.dsl.* +import org.junit.Assert.* + +private fun setup(): Pine { + val pine = TestablePine() + + pine.get("/test", fun(request, response): Response { + return response.withText("This is only a test") + }) + + pine.testingPreflight() + + return pine +} + +class FunctionResourceTest : Spek({ + describe("A functional resource") { + val pine = setup() + + it("Should respond to a GET request to /test") { + val response = pine.handleRequest(Request(mapOf(), "", mapOf(), "/test", Method.GET, hashMapOf())) + + assertEquals(200, response.status) + assertEquals("This is only a test", response.body) + assertEquals("text/plain", response.headers["Content-Type"]!!) + } + + it("Should not respond to other routes") { + val response = pine.handleRequest(Request(mapOf(), "", mapOf(), "/", Method.GET, hashMapOf())) + + assertEquals(404, response.status) + assertNotEquals("This is only a test", response.body) + } + } +}) \ No newline at end of file diff --git a/src/test/kotlin/com/icosillion/pine/test/ValidatorTest.kt b/src/test/kotlin/com/icosillion/pine/test/ValidatorTest.kt new file mode 100644 index 0000000..9e734de --- /dev/null +++ b/src/test/kotlin/com/icosillion/pine/test/ValidatorTest.kt @@ -0,0 +1,99 @@ +package com.icosillion.pine.test + +import com.github.salomonbrys.kotson.jsonArray +import com.github.salomonbrys.kotson.jsonObject +import com.github.salomonbrys.kotson.toJson +import com.icosillion.pine.validator.Rule +import com.icosillion.pine.validator.arraySchema +import com.icosillion.pine.validator.objectSchema +import org.junit.Test +import org.junit.Assert.* + +class ValidatorTest { + + @Test + fun stringTest() { + val schema = objectSchema( + "string" to Rule().string() + ) + + assertTrue(schema.validate(jsonObject( + "string" to "test" + ))) + + assertFalse(schema.validate(jsonObject( + "string" to 42 + ))) + + assertFalse(schema.validate(jsonObject( + "string" to jsonObject() + ))) + } + + @Test + fun arrayTest() { + val elementSchema = objectSchema( + "name" to Rule().string(), + "age" to Rule().integer() + ) + + val schema = arraySchema(elementSchema) + + assertTrue(schema.validate(jsonArray( + jsonObject( + "name" to "John Smith", + "age" to 56 + ), + jsonObject( + "name" to "Mary Denzel", + "age" to 24 + ) + ))) + + assertFalse(schema.validate(jsonArray( + jsonObject( + "name" to "John Smith", + "age" to 56 + ), + jsonObject( + "name" to "Mary Denzel", + "age" to 24 + ), + jsonObject( + "naam" to "Mr Invalid", + "age" to 12 + ) + ))) + } + + @Test + fun maxTest() { + //Test Strings + val stringRule = Rule().string().max(5) + + assertTrue(stringRule.validate("Test".toJson())) + assertTrue(stringRule.validate("Test1".toJson())) + + assertFalse(stringRule.validate("Test22".toJson())) + + //Test Integers + val integerRule = Rule().integer().max(5) + + assertTrue(integerRule.validate(4.toJson())) + assertTrue(integerRule.validate(5.toJson())) + + assertFalse(integerRule.validate(6.toJson())) + } + + @Test + fun integerTest() { + val rule = Rule().integer() + + assertTrue(rule.validate(5.toJson())) + assertTrue(rule.validate((-1).toJson())) + assertTrue(rule.validate(12.toJson())) + + assertFalse(rule.validate((12.5).toJson())) + assertFalse(rule.validate("Test".toJson())) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/icosillion/pine/test/harness/TestablePine.kt b/src/test/kotlin/com/icosillion/pine/test/harness/TestablePine.kt new file mode 100644 index 0000000..b183fa8 --- /dev/null +++ b/src/test/kotlin/com/icosillion/pine/test/harness/TestablePine.kt @@ -0,0 +1,10 @@ +package com.icosillion.pine.test.harness + +import com.icosillion.pine.Pine + +class TestablePine : Pine() { + + fun testingPreflight() { + this.preflight() + } +} \ No newline at end of file