├── gradle.properties ├── wasabi-core ├── src │ ├── test │ │ ├── resources │ │ │ └── fileupload-test.txt │ │ └── main │ │ │ └── kotlin │ │ │ └── org │ │ │ └── wasabifx │ │ │ └── wasabi │ │ │ └── test │ │ │ ├── BinaryContentSpecs.kt │ │ │ ├── InMemorySessionStorageSpec.kt │ │ │ ├── FavIconInterceptorSpecs.kt │ │ │ ├── Http2ClientTest.kt │ │ │ ├── CachingSpecs.kt │ │ │ ├── StartupShutdownSpecs.kt │ │ │ ├── RedirectSpecs.kt │ │ │ ├── ResponseSpecs.kt │ │ │ ├── AutoLocationInterceptorSpecs.kt │ │ │ ├── FileUploadSpec.kt │ │ │ ├── FileBasedErrorInterceptorSpecs.kt │ │ │ ├── BasicAuthenticationInterceptorSpecs.kt │ │ │ ├── UrlRequestingSpecs.kt │ │ │ ├── ConfigSpecs.kt │ │ │ ├── SerializerSpecs.kt │ │ │ ├── AutoOptionsInterceptorSpecs.kt │ │ │ ├── ExceptionHandlerSpecs.kt │ │ │ ├── ConfigStorageSpecs.kt │ │ │ ├── SessionManagementInterceptorSpecs.kt │ │ │ ├── Tester.kt │ │ │ ├── BodyParametersSpecs.kt │ │ │ ├── ContentNegotiatioParserInterceptorSpecs.kt │ │ │ ├── StaticFileInterceptorSpecs.kt │ │ │ ├── Helpers.kt │ │ │ ├── TestClient.kt │ │ │ ├── RoutesSpecs.kt │ │ │ ├── CorsSpecs.kt │ │ │ ├── HeaderSpecs.kt │ │ │ ├── ContentNegotiationSpecs.kt │ │ │ └── TestClientSpecs.kt │ └── main │ │ ├── kotlin │ │ └── org │ │ │ └── wasabifx │ │ │ └── wasabi │ │ │ ├── app │ │ │ ├── FileUpload.kt │ │ │ ├── AppConfiguration.kt │ │ │ └── AppServer.kt │ │ │ ├── protocol │ │ │ ├── http │ │ │ │ ├── InvalidHeaderNameException.kt │ │ │ │ ├── NegotiateOn.kt │ │ │ │ ├── Cookie.kt │ │ │ │ ├── Session.kt │ │ │ │ ├── CacheControl.kt │ │ │ │ ├── CORSEntry.kt │ │ │ │ ├── StatusCodes.kt │ │ │ │ ├── ProtocolNegotiator.kt │ │ │ │ ├── HttpServer.kt │ │ │ │ ├── ContentType.kt │ │ │ │ ├── Response.kt │ │ │ │ └── Request.kt │ │ │ ├── websocket │ │ │ │ ├── Channel.kt │ │ │ │ ├── Response.kt │ │ │ │ ├── ChannelHandler.kt │ │ │ │ ├── Utilities.kt │ │ │ │ ├── WebSocketProtocolHandler.kt │ │ │ │ └── WebSocketFrameHandler.kt │ │ │ └── http2 │ │ │ │ ├── Http2HandlerBuilder.kt │ │ │ │ └── Http2Handler.kt │ │ │ ├── routing │ │ │ ├── RouteNotFoundException.kt │ │ │ ├── ExceptionHandlerLocator.kt │ │ │ ├── InvalidMethodException.kt │ │ │ ├── RouteAlreadyExistsException.kt │ │ │ ├── RouteLocator.kt │ │ │ ├── ExceptionHandler.kt │ │ │ ├── ChannelLocator.kt │ │ │ ├── ChannelAlreadyExistsException.kt │ │ │ ├── RouteException.kt │ │ │ ├── RouteHandler.kt │ │ │ ├── ClassMatchingExceptionHandlerLocator.kt │ │ │ ├── Route.kt │ │ │ ├── PatternMatchingChannelLocator.kt │ │ │ ├── PatternAndVerbMatchingRouteLocator.kt │ │ │ └── Routes.kt │ │ │ ├── configuration │ │ │ ├── InvalidConfigurationException.kt │ │ │ └── ConfigurationStorage.kt │ │ │ ├── interceptors │ │ │ ├── InterceptOn.kt │ │ │ ├── InterceptorEntry.kt │ │ │ ├── SessionStorage.kt │ │ │ ├── Interceptor.kt │ │ │ ├── LoggingInterceptor.kt │ │ │ ├── AutoLocationInterceptor.kt │ │ │ ├── FavIconInterceptor.kt │ │ │ ├── AuthenticationInterceptor.kt │ │ │ ├── FileBasedErrorInterceptor.kt │ │ │ ├── ETagInterceptor.kt │ │ │ ├── AutoOptionsInterceptor.kt │ │ │ ├── ContentNegotiationInterceptor.kt │ │ │ ├── SessionManagementInterceptor.kt │ │ │ ├── StaticFileInterceptor.kt │ │ │ ├── ContentNegotiationParserInterceptor.kt │ │ │ └── CORSInterceptor.kt │ │ │ ├── serializers │ │ │ ├── TextPlainSerializer.kt │ │ │ ├── JsonSerializer.kt │ │ │ ├── Serializer.kt │ │ │ └── XmlSerializer.kt │ │ │ ├── authentication │ │ │ ├── Authentication.kt │ │ │ └── BasicAuthentication.kt │ │ │ ├── events │ │ │ └── events.kt │ │ │ ├── encoding │ │ │ └── EncodingDecoding.kt │ │ │ ├── deserializers │ │ │ ├── JsonDeserializer.kt │ │ │ ├── Deserializer.kt │ │ │ └── MultiPartFormDataDeserializer.kt │ │ │ ├── storage │ │ │ └── InMemorySessionStorage.kt │ │ │ └── core │ │ │ ├── NettyPipelineInitializer.kt │ │ │ └── HttpPipelineInitializer.kt │ │ └── resources │ │ └── wasabi.yaml ├── testData │ ├── public │ │ ├── file with spaces in filename.txt │ │ ├── style.css │ │ ├── second.html │ │ ├── 404.html │ │ ├── error.html │ │ ├── pic.png │ │ ├── favicon.ico │ │ ├── test.html │ │ └── index.html │ ├── production_bad_json.json │ ├── production_bad_property.json │ └── production.json ├── build.gradle └── tools │ ├── sample.gradle │ └── build.gradle ├── settings.gradle ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── package.gradle └── publish.gradle ├── wasabi-samples ├── src │ └── main │ │ └── kotlin │ │ └── org │ │ └── wasabifx │ │ └── wasabi │ │ └── samples │ │ ├── servingStaticFiles.kt │ │ ├── helloWorld.kt │ │ └── websocket.kt └── build.gradle ├── .gitignore ├── gradlew.bat └── gradlew /gradle.properties: -------------------------------------------------------------------------------- 1 | BINTRAY_USER= 2 | BINTRAY_KEY= 3 | -------------------------------------------------------------------------------- /wasabi-core/src/test/resources/fileupload-test.txt: -------------------------------------------------------------------------------- 1 | Text -------------------------------------------------------------------------------- /wasabi-core/testData/public/file with spaces in filename.txt: -------------------------------------------------------------------------------- 1 | lorem ipsum -------------------------------------------------------------------------------- /wasabi-core/testData/production_bad_json.json: -------------------------------------------------------------------------------- 1 | { 2 | p ort: 5000, 3 | 4 | } -------------------------------------------------------------------------------- /wasabi-core/testData/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: aqua; 3 | } -------------------------------------------------------------------------------- /wasabi-core/testData/production_bad_property.json: -------------------------------------------------------------------------------- 1 | { 2 | "invalid_property": "whatever" 3 | } -------------------------------------------------------------------------------- /wasabi-core/testData/public/second.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | a second file 4 | 5 | 6 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'wasabi' 2 | include 'wasabi-core' 3 | include 'wasabi-samples' 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasabifx/wasabi/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /wasabi-core/testData/public/404.html: -------------------------------------------------------------------------------- 1 | Custom File 404 -------------------------------------------------------------------------------- /wasabi-core/testData/public/error.html: -------------------------------------------------------------------------------- 1 | Standard Error File -------------------------------------------------------------------------------- /wasabi-core/testData/public/pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasabifx/wasabi/HEAD/wasabi-core/testData/public/pic.png -------------------------------------------------------------------------------- /wasabi-samples/src/main/kotlin/org/wasabifx/wasabi/samples/servingStaticFiles.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.samples 2 | 3 | -------------------------------------------------------------------------------- /wasabi-core/testData/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasabifx/wasabi/HEAD/wasabi-core/testData/public/favicon.ico -------------------------------------------------------------------------------- /wasabi-core/testData/public/test.html: -------------------------------------------------------------------------------- 1 | This is an example static file -------------------------------------------------------------------------------- /wasabi-core/testData/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 5000, 3 | "welcomeMessage": "Welcome to Wasabi!", 4 | "enableLogging": true 5 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/app/FileUpload.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.app 2 | 3 | data class FileUpload(val name: String, val data: ByteArray) -------------------------------------------------------------------------------- /wasabi-samples/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | compile project(':wasabi-core') 3 | compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 4 | } 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/* 2 | ~$* 3 | .idea/workspace.xml 4 | .DS_Store/* 5 | .DS_Store 6 | lib/kotlin-runtime.jar 7 | build/ 8 | .gradle/ 9 | kobaltBuild/ 10 | .kobalt 11 | .idea 12 | wasabi.iml 13 | 14 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/InvalidHeaderNameException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | class InvalidHeaderNameException(message: String): Exception(message) { 4 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/NegotiateOn.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | enum class NegotiateOn { 4 | 5 | AcceptHeader, 6 | Extension, 7 | QueryParameter 8 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/RouteNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | class RouteNotFoundException(message: String = "Routing entry not found"): Exception(message) { 4 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/configuration/InvalidConfigurationException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.configuration 2 | 3 | 4 | class InvalidConfigurationException(message: String): Exception(message) { 5 | 6 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/ExceptionHandlerLocator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | interface ExceptionHandlerLocator { 4 | fun findExceptionHandlers(exception: Exception): RouteException 5 | } 6 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/InterceptOn.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | enum class InterceptOn { 4 | PreRequest, 5 | PreExecution, 6 | PostExecution, 7 | PostRequest, 8 | Error 9 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/InterceptorEntry.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | 4 | data class InterceptorEntry(val interceptor: Interceptor, val path: String, val interceptOn: InterceptOn = InterceptOn.PreRequest) -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/Cookie.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import io.netty.handler.codec.http.cookie.DefaultCookie 4 | 5 | 6 | class Cookie(name: String?, value: String?) : DefaultCookie(name, value) -------------------------------------------------------------------------------- /wasabi-core/testData/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Nov 01 20:43:11 CET 2016 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-3.1-all.zip 7 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/SessionStorage.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.protocol.http.Session 4 | 5 | interface SessionStorage { 6 | fun storeSession(session: Session) 7 | fun loadSession(sessionID: String): Session 8 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/serializers/TextPlainSerializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.serializers 2 | 3 | 4 | class TextPlainSerializer(): Serializer("text/plain") { 5 | override fun serialize(input: Any): String { 6 | return input.toString() 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/InvalidMethodException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | 5 | class InvalidMethodException(message: String = "Invalid method exception", val allowedMethods: Array) : Exception(message) { 6 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/Interceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.protocol.http.Response 4 | import org.wasabifx.wasabi.protocol.http.Request 5 | 6 | interface Interceptor { 7 | fun intercept(request: Request, response: Response): Boolean 8 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/RouteAlreadyExistsException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import org.wasabifx.wasabi.routing.Route 4 | 5 | class RouteAlreadyExistsException(existingRoute: Route): Exception("Path ${existingRoute.path} with method ${existingRoute.method} already exists") { 6 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/RouteLocator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | 5 | interface RouteLocator { 6 | fun findRouteHandlers(path: String, method: HttpMethod): Route 7 | fun compareRouteSegments(route1: Route, path: String): Boolean 8 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/resources/wasabi.yaml: -------------------------------------------------------------------------------- 1 | # Example configuration using defaults. 2 | wasabi: { 3 | port: 3000, 4 | welcomeMessage: "Server starting", 5 | enableContentNegotiation: true, 6 | enableLogging: true, 7 | enableAutoOptions: false, 8 | enableCORSGlobally: false, 9 | sessionLifetime: 600, 10 | enableXML11: false 11 | } 12 | 13 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/ExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | 6 | class ExceptionHandler(val request: Request, val response: Response, val exception: Exception) {} 7 | 8 | fun exceptionHandler(f: ExceptionHandler.()->Unit) = f -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/websocket/Channel.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.websocket 2 | 3 | /** 4 | * Created with IntelliJ IDEA. 5 | * User: swishy 6 | * Date: 5/11/13 7 | * Time: 9:58 PM 8 | * To change this template use File | Settings | File Templates. 9 | */ 10 | data class Channel (val path: String, val handler: ChannelHandler.() -> Unit) -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/websocket/Response.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.websocket 2 | 3 | import io.netty.handler.codec.http.websocketx.WebSocketFrame 4 | 5 | /** 6 | * Wrapper to allow interceptors / serializers to function within the 7 | * WebSocket realm also.s 8 | */ 9 | class Response() { 10 | var frame: WebSocketFrame? = null 11 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/authentication/Authentication.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.authentication 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | 6 | /** 7 | * Created by cnwdaa1 on 15/09/2015. 8 | */ 9 | interface Authentication { 10 | fun authenticate(request: Request, response: Response) : Boolean 11 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/serializers/JsonSerializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.serializers 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | 5 | class JsonSerializer(): Serializer("application/json", "application/vnd\\.\\w*\\+json") { 6 | override fun serialize(input: Any): String { 7 | val objectMapper = ObjectMapper() 8 | return objectMapper.writeValueAsString(input)!! 9 | } 10 | } -------------------------------------------------------------------------------- /wasabi-core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.zoltu.kotlin" version "1.0.3" 3 | id "com.zoltu.git-versioning" version "2.0.21" 4 | id "com.jfrog.bintray" version "1.7" 5 | id "maven-publish" 6 | } 7 | 8 | 9 | apply from: '../gradle/package.gradle' 10 | apply from: '../gradle/publish.gradle' 11 | 12 | 13 | 14 | sourceSets { 15 | main.java.srcDirs += 'src/main/kotlin' 16 | test.java.srcDirs += 'src/test/main/kotlin' 17 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/ChannelLocator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import org.wasabifx.wasabi.protocol.websocket.Channel 4 | 5 | /** 6 | * Created with IntelliJ IDEA. 7 | * User: swishy 8 | * Date: 5/11/13 9 | * Time: 10:45 PM 10 | * To change this template use File | Settings | File Templates. 11 | */ 12 | interface ChannelLocator { 13 | fun findChannelHandler(path: String): Channel 14 | } -------------------------------------------------------------------------------- /gradle/package.gradle: -------------------------------------------------------------------------------- 1 | group 'org.wasabifx' 2 | 3 | println "##teamcity[buildNumber \'${version}\']" 4 | 5 | jar { 6 | manifest { 7 | attributes( 8 | "Implementation-Title": "wasabi", 9 | "Implementation-Vendor": "wasabifx" 10 | ) 11 | } 12 | } 13 | 14 | task sourceJar(type: Jar) { 15 | from sourceSets.main.allSource 16 | classifier "sources" 17 | setManifest(jar.getManifest()) 18 | } 19 | -------------------------------------------------------------------------------- /wasabi-samples/src/main/kotlin/org/wasabifx/wasabi/samples/helloWorld.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.samples 2 | 3 | import org.wasabifx.wasabi.app.AppConfiguration 4 | import org.wasabifx.wasabi.app.AppServer 5 | 6 | fun main(args: Array) { 7 | 8 | val server = AppServer(AppConfiguration(enableLogging = false)) 9 | 10 | server.get("/", { 11 | response.send("Hello World!") 12 | }) 13 | 14 | server.start() 15 | } 16 | 17 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/Session.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import org.joda.time.DateTime 4 | 5 | 6 | class Session(val id: String) { 7 | // TODO wire in use of config setting 8 | var expirationDate = DateTime.now()!!.plusSeconds(600) 9 | var data: Any? = null 10 | 11 | fun extendSession() 12 | { 13 | expirationDate = DateTime.now()!!.plusSeconds(600) 14 | } 15 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/events/events.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.events 2 | 3 | import io.netty.channel.Channel 4 | 5 | class Event { 6 | private val handlers = arrayListOf<(Event.(T) -> Unit)>() 7 | operator fun plusAssign(handler: Event.(T) -> Unit) { handlers.add(handler) } 8 | operator fun invoke(value: T) { for (handler in handlers) handler(value) } 9 | } 10 | 11 | val connectionInactive = Event() 12 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/ChannelAlreadyExistsException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import org.wasabifx.wasabi.protocol.websocket.Channel 4 | 5 | /** 6 | * Created with IntelliJ IDEA. 7 | * User: swishy 8 | * Date: 5/11/13 9 | * Time: 10:12 PM 10 | * To change this template use File | Settings | File Templates. 11 | */ 12 | class ChannelAlreadyExistsException(existingChannel: Channel): Exception("Path ${existingChannel.path} already exists") { 13 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/serializers/Serializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.serializers 2 | 3 | abstract class Serializer(vararg val mediaTypes: String) { 4 | open fun canSerialize(mediaType: String): Boolean { 5 | for (supportedMediaType in mediaTypes) { 6 | if (mediaType.matches(supportedMediaType.toRegex())) { 7 | return true 8 | } 9 | } 10 | return false 11 | } 12 | abstract fun serialize(input: Any): String 13 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/encoding/EncodingDecoding.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.encoding 2 | 3 | import org.apache.commons.codec.binary.Base64 4 | 5 | 6 | fun String.decodeBase64(encoding: String): String { 7 | if (encoding == "base64") { 8 | return String(Base64.decodeBase64(this)!!) 9 | } else { 10 | throw IllegalArgumentException() 11 | } 12 | } 13 | 14 | fun String.encodeBase64(): String { 15 | return Base64.encodeBase64(this.toByteArray()).toString() 16 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/websocket/ChannelHandler.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.websocket 2 | 3 | import io.netty.channel.ChannelHandlerContext 4 | import io.netty.handler.codec.http.websocketx.WebSocketFrame 5 | 6 | class ChannelHandler(val ctx: ChannelHandlerContext?, val frame: WebSocketFrame, val response: Response) { 7 | 8 | var executeNext = false 9 | 10 | fun next() { 11 | executeNext = true 12 | } 13 | 14 | } 15 | 16 | fun channelHandler(f: ChannelHandler.()->Unit) = f 17 | -------------------------------------------------------------------------------- /gradle/publish.gradle: -------------------------------------------------------------------------------- 1 | artifacts { 2 | archives sourceJar 3 | } 4 | 5 | publishing { 6 | publications { 7 | MyMavenPublication(MavenPublication) { 8 | from components.java 9 | artifactId 'wasabi' 10 | artifact sourceJar 11 | } 12 | } 13 | } 14 | 15 | bintray { 16 | user = "${BINTRAY_USER}" 17 | key = "${BINTRAY_KEY}" 18 | publications = ['MyMavenPublication'] 19 | publish = true 20 | pkg { 21 | userOrg = 'wasabifx' 22 | repo = 'wasabifx' 23 | name = 'wasabi' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/RouteException.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | class RouteException(val exceptionClass: String, val handler: ExceptionHandler.() -> Unit){ 4 | 5 | override fun equals(other: Any?): Boolean { 6 | if (this === other) return true 7 | if (other === null || javaClass !== other.javaClass) return false 8 | val otherRouteException = other as RouteException? 9 | return exceptionClass == otherRouteException?.exceptionClass 10 | } 11 | 12 | override fun hashCode(): Int = 31 * exceptionClass.hashCode() 13 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/deserializers/JsonDeserializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.deserializers 2 | 3 | import java.util.HashMap 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | 6 | 7 | class JsonDeserializer: Deserializer("application/json") { 8 | // TODO: This is temp as it doesn't correctly handle x.y properties 9 | 10 | @Suppress("UNCHECKED_CAST") 11 | override fun deserialize(input: Any): HashMap { 12 | val mapper = ObjectMapper() 13 | val map = mapper.readValue(input as String, HashMap::class.java)!! 14 | return map as HashMap 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/CacheControl.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | enum class CacheControl { 4 | 5 | NoCache { 6 | override fun toString(): String { 7 | return "no-cache" 8 | } 9 | }, 10 | Private { 11 | override fun toString(): String { 12 | return "private" 13 | } 14 | }, 15 | Public { 16 | override fun toString(): String { 17 | return "public" 18 | } 19 | }, 20 | NoStore { 21 | override fun toString(): String { 22 | return "no-store" 23 | } 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/RouteHandler.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | 6 | 7 | class RouteHandler(val request: Request, val response: Response) { 8 | 9 | var executeNext = false 10 | 11 | 12 | fun next() { 13 | executeNext = true 14 | } 15 | 16 | } 17 | 18 | fun routeHandler(f: RouteHandler.()->Unit): RouteHandler.() -> Unit = f 19 | 20 | fun String.with(handler : Response.() -> Unit) : Pair Unit> = this to handler 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/BinaryContentSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import kotlin.test.assertEquals 4 | import org.junit.Test as spec 5 | 6 | class BinaryContentSpecs: TestServerContext() { 7 | 8 | @spec fun request_with_get_should_contain_all_fields() { 9 | 10 | TestServer.appServer.get("/binary/thing", 11 | { 12 | response.send(byteArrayOf(1,2,3,4,5,6,7,8), "application/octet-stream") 13 | }) 14 | 15 | val response = get("http://localhost:${TestServer.definedPort}/binary/thing") 16 | assertEquals(8, response.body?.length) 17 | } 18 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/InMemorySessionStorageSpec.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.protocol.http.Session 4 | import org.wasabifx.wasabi.storage.InMemorySessionStorage 5 | import kotlin.test.assertTrue 6 | import org.junit.Test as spec 7 | 8 | class InMemorySessionStorageSpec { 9 | 10 | @spec fun loading_session_with_non_existing_session_id_should_return_new_session() { 11 | val inMemorySessionStorage = InMemorySessionStorage() 12 | 13 | val session = inMemorySessionStorage.loadSession("this-id-does-not-exist-for-sure") 14 | assertTrue(session is Session) 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/ClassMatchingExceptionHandlerLocator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | /** 4 | * Created by condaa1 on 24/06/16. 5 | */ 6 | class ClassMatchingExceptionHandlerLocator(val handlers: Set) : ExceptionHandlerLocator { 7 | 8 | override fun findExceptionHandlers(exception: Exception): RouteException { 9 | val matchingHandler = handlers.filter { it.exceptionClass == exception.javaClass.name} 10 | if (matchingHandler.count() == 0) { 11 | return handlers.filter { it.exceptionClass.isBlank() }.firstOrNull()!! 12 | } 13 | return matchingHandler.firstOrNull()!! 14 | } 15 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/FavIconInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import org.wasabifx.wasabi.interceptors.serveFavIconAs 5 | import kotlin.test.assertEquals 6 | import java.io.File 7 | 8 | class FavIconInterceptorSpecs: TestServerContext() { 9 | 10 | @spec fun requesting_favicon_should_return_favicon() { 11 | TestServer.appServer.serveFavIconAs("testData${File.separatorChar}public${File.separatorChar}favicon.ico") 12 | val response = get("http://localhost:${TestServer.definedPort}/favicon.ico", hashMapOf()) 13 | assertEquals(200, response.statusCode) 14 | } 15 | 16 | } 17 | 18 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/Http2ClientTest.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.Request 5 | 6 | /** 7 | * Created by condaa1 on 15/04/16. 8 | */ 9 | 10 | var client = OkHttpClient() 11 | 12 | fun main(args: Array) { 13 | 14 | var request = Request.Builder() 15 | .url("http://localhost:3000/js") 16 | .header("SecurityToken", "b08c85073c1a2d02") 17 | .header("Accept", "application/json") 18 | .header("Accept-Encoding", "gzip, deflate").build() 19 | 20 | var response = client.newCall(request).execute() 21 | System.out.println(response.body().string()) 22 | } 23 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/storage/InMemorySessionStorage.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.storage 2 | 3 | import org.wasabifx.wasabi.protocol.http.Session 4 | import org.wasabifx.wasabi.interceptors.SessionStorage 5 | import java.util.concurrent.ConcurrentHashMap 6 | 7 | class InMemorySessionStorage: SessionStorage { 8 | 9 | val inMemorySession = ConcurrentHashMap() 10 | 11 | override fun storeSession(session: Session) { 12 | inMemorySession.put(session.id, session) 13 | } 14 | override fun loadSession(sessionID: String): Session { 15 | inMemorySession.putIfAbsent(sessionID, Session(sessionID)) 16 | 17 | return inMemorySession.get(sessionID)!! 18 | } 19 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/CORSEntry.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | 5 | class CORSEntry(val path: String = "*", 6 | val origins: String = "*", 7 | val methods: Set? = CORSEntry.ALL_AVAILABLE_METHODS, 8 | val headers: String = "Origin, X-Requested-With, Content-Type, Accept", 9 | val credentials: String = "", 10 | val preflightMaxAge: String = "") { 11 | 12 | companion object { 13 | val NO_METHODS = emptySet() 14 | val ALL_AVAILABLE_METHODS = null 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/LoggingInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.wasabifx.wasabi.app.AppServer 5 | import org.wasabifx.wasabi.protocol.http.Request 6 | import org.wasabifx.wasabi.protocol.http.Response 7 | 8 | class LoggingInterceptor: Interceptor { 9 | override fun intercept(request: Request, response: Response): Boolean { 10 | var logger = LoggerFactory.getLogger(LoggingInterceptor::class.java) 11 | logger!!.info("[${request.method}] - ${request.uri}") 12 | return true 13 | } 14 | 15 | } 16 | 17 | fun AppServer.logRequests() { 18 | intercept(LoggingInterceptor(), "*", InterceptOn.PreExecution) 19 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/serializers/XmlSerializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.serializers 2 | 3 | import com.fasterxml.jackson.dataformat.xml.XmlMapper 4 | import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator 5 | import org.wasabifx.wasabi.app.configuration 6 | 7 | class XmlSerializer: Serializer("text/xml", "text/vnd\\.\\w*\\+xml", "application/xml", "application/vnd\\.\\w*\\+xml") { 8 | override fun serialize(input: Any): String { 9 | val feature = if (configuration!!.enableXML11) ToXmlGenerator.Feature.WRITE_XML_1_1 else ToXmlGenerator.Feature.WRITE_XML_DECLARATION 10 | val xmlMapper = XmlMapper().configure(feature, true) 11 | return xmlMapper.writeValueAsString(input)!! 12 | } 13 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/CachingSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import kotlin.test.assertEquals 5 | 6 | class CachingSpecs: TestServerContext() { 7 | 8 | @spec fun setting_cache_control_should_set_cache_control_header_in_response() { 9 | 10 | TestServer.appServer.get("/cachePolicy",{ 11 | response.cacheControl = "no-cache" 12 | response.send("no-cache") 13 | } ) 14 | 15 | val response = get("http://localhost:${TestServer.definedPort}/cachePolicy", hashMapOf()) 16 | 17 | val cacheControlHeader = response.headers.firstOrNull({ it.name == "Cache-Control" })?.value 18 | 19 | assertEquals("no-cache", cacheControlHeader) 20 | 21 | 22 | 23 | 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/StartupShutdownSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | 4 | import java.net.Socket 5 | import java.net.InetSocketAddress 6 | import org.junit.Test as spec 7 | import kotlin.test.assertEquals 8 | 9 | class StartupShutdownSpecs : TestServerContext() { 10 | 11 | @spec fun starting_an_app_server_should_open_the_specified_port_and_listen_for_connections() { 12 | 13 | val socket = Socket() 14 | val socketAddress = InetSocketAddress("localhost", TestServer.definedPort) 15 | 16 | socket.connect(socketAddress) 17 | //socket.close() 18 | 19 | // not really required as socket would throw exception if it cannot connect 20 | assertEquals(true, socket.isConnected) 21 | socket.close() 22 | } 23 | 24 | 25 | } 26 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/RedirectSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import kotlin.test.assertEquals 5 | import org.wasabifx.wasabi.protocol.http.StatusCodes 6 | 7 | class RedirectSpecs: TestServerContext() { 8 | 9 | @spec fun redirect_should_set_status_code_to_found_and_location_header_to_new_location() { 10 | 11 | TestServer.appServer.get("/redirect", { 12 | response.redirect("http://www.google.com") 13 | }) 14 | 15 | val response = get("http://localhost:${TestServer.definedPort}/redirect", hashMapOf()) 16 | 17 | assertEquals(StatusCodes.Found.code, response.statusCode) 18 | assertEquals("http://www.google.com", response.headers.filter { it.name == "Location" }.first().value) 19 | 20 | 21 | } 22 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/Route.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import java.util.* 5 | 6 | 7 | class Route(val path: String, val method: HttpMethod, val params: HashMap, vararg val handler: RouteHandler.() -> Unit) { 8 | override fun equals(other: Any?): Boolean { 9 | if (this === other) return true 10 | if (other?.javaClass != javaClass) return false 11 | 12 | other as Route 13 | 14 | if (path != other.path) return false 15 | if (method != other.method) return false 16 | 17 | return true 18 | } 19 | 20 | override fun hashCode(): Int { 21 | var result = path.hashCode() 22 | result = 31 * result + method.hashCode() 23 | return result 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/PatternMatchingChannelLocator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import org.wasabifx.wasabi.protocol.websocket.Channel 4 | import java.util.ArrayList 5 | 6 | /** 7 | * Created with IntelliJ IDEA. 8 | * User: swishy 9 | * Date: 5/11/13 10 | * Time: 10:47 PM 11 | * To change this template use File | Settings | File Templates. 12 | */ 13 | class PatternMatchingChannelLocator(val channels: ArrayList) : ChannelLocator { 14 | override fun findChannelHandler(path: String): Channel { 15 | val matchingChannel = channels.filter { it.path == path } 16 | if (matchingChannel.count() == 0) { 17 | throw RouteNotFoundException() 18 | } 19 | 20 | // We should only ever have one handler for a websocket channel 21 | return matchingChannel.firstOrNull()!! 22 | } 23 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/AutoLocationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | import org.wasabifx.wasabi.protocol.http.StatusCodes 6 | import org.wasabifx.wasabi.app.AppServer 7 | 8 | 9 | class AutoLocationInterceptor(): Interceptor { 10 | override fun intercept(request: Request, response: Response): Boolean { 11 | if (response.statusCode == StatusCodes.Created.code && response.resourceId != null) { 12 | response.location = "${request.protocol}://${request.host}:${request.port}${request.path}/${response.resourceId}" 13 | } 14 | 15 | return true 16 | } 17 | } 18 | 19 | fun AppServer.enableAutoLocation(path: String = "*") { 20 | intercept(AutoLocationInterceptor(), path, interceptOn = InterceptOn.PostRequest) 21 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/FavIconInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | import io.netty.handler.codec.http.HttpMethod 6 | import org.wasabifx.wasabi.app.AppServer 7 | 8 | 9 | class FavIconInterceptor(val icon: String): Interceptor { 10 | 11 | override fun intercept(request: Request, response: Response): Boolean { 12 | if (request.method == HttpMethod.GET && request.uri.compareTo("/favicon.ico", ignoreCase = true) == 0) { 13 | val path = icon.trim('/') 14 | response.sendFile(path, "image/x-icon") 15 | return false 16 | } else { 17 | return true 18 | } 19 | } 20 | 21 | } 22 | 23 | fun AppServer.serveFavIconAs(icon: String) { 24 | intercept(FavIconInterceptor(icon)) 25 | } 26 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/authentication/BasicAuthentication.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.authentication 2 | 3 | import org.wasabifx.wasabi.encoding.decodeBase64 4 | import org.wasabifx.wasabi.protocol.http.Request 5 | import org.wasabifx.wasabi.protocol.http.Response 6 | 7 | class BasicAuthentication(val realm: String, val callback: (String, String) -> Boolean, val path: String = "*") : Authentication { 8 | override fun authenticate(request: Request, response: Response): Boolean { 9 | if (request.authorization != "") { 10 | val credentialsBase64Encoded = request.authorization.dropWhile { it != ' ' } 11 | val credentialsDecoded = credentialsBase64Encoded.decodeBase64("base64") 12 | val credentials = credentialsDecoded.split(':') 13 | if (callback(credentials[0], credentials[1])) { 14 | return true 15 | } 16 | } 17 | response.addRawHeader("WWW-Authenticate", "Basic Realm=${realm}") 18 | return false 19 | } 20 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/deserializers/Deserializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.deserializers 2 | 3 | import java.util.HashMap 4 | 5 | abstract class Deserializer(vararg val mediaTypes: String) { 6 | open fun canDeserialize(mediaType: String): Boolean { 7 | for (supportedMediaType in mediaTypes) { 8 | /** 9 | * Content type may provide a charset: 10 | * application/x-www-form-urlencoded; charset=utf-8 11 | * (we SHOULD match this) 12 | * 13 | * Or have a custom suffix: 14 | * application/x-www-form-urlencoded-v2 15 | * (we SHOULDN'T match this) 16 | * 17 | * And remember that HTTP headers are case-insensitive. 18 | */ 19 | if (mediaType.matches("$supportedMediaType(;.*)?".toRegex(RegexOption.IGNORE_CASE))) { 20 | return true 21 | } 22 | } 23 | return false 24 | } 25 | abstract fun deserialize(input: Any): HashMap 26 | } 27 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/websocket/Utilities.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.websocket 2 | 3 | import io.netty.channel.Channel 4 | import io.netty.handler.codec.http.websocketx.WebSocketFrame 5 | import java.util.* 6 | 7 | val channelClients: HashMap> = HashMap() 8 | 9 | fun broadcast(channel: String, message: WebSocketFrame) { 10 | val clients = channelClients[channel] 11 | clients!!.forEach { 12 | client -> sendMessage(client, message) 13 | } 14 | // Release the message as it will not get done as flush is not called in this context. 15 | message.release() 16 | } 17 | 18 | fun respond(channel: Channel, message: WebSocketFrame) { 19 | sendMessage(channel, message) 20 | message.release() 21 | } 22 | 23 | private fun sendMessage(channel: Channel, message: WebSocketFrame) { 24 | // Make sure we copy the message otherwise it gets released as soon as flush is called. 25 | val newMessage = message.copy() 26 | channel.write(newMessage) 27 | channel.flush() 28 | } 29 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/AuthenticationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | 4 | import org.wasabifx.wasabi.app.AppServer 5 | import org.wasabifx.wasabi.authentication.Authentication 6 | import org.wasabifx.wasabi.protocol.http.ContentType 7 | import org.wasabifx.wasabi.protocol.http.Request 8 | import org.wasabifx.wasabi.protocol.http.Response 9 | import org.wasabifx.wasabi.protocol.http.StatusCodes 10 | 11 | class AuthenticationInterceptor(val implementation: Authentication ) : Interceptor { 12 | override fun intercept(request: Request, response: Response): Boolean { 13 | if (implementation.authenticate(request, response)) { 14 | return true 15 | } 16 | response.setStatus(StatusCodes.Unauthorized) 17 | response.contentType = ContentType.Companion.Text.Plain.toString() 18 | response.send("Authentication Failed") 19 | return false 20 | } 21 | } 22 | 23 | fun AppServer.useAuthentication(implementation: Authentication, path: String = "*") { 24 | intercept(AuthenticationInterceptor(implementation), path) 25 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/FileBasedErrorInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | import java.io.File 6 | import org.wasabifx.wasabi.app.AppServer 7 | 8 | class FileBasedErrorInterceptor(val folder: String, val fileExtensions: String = "html", val fallbackGenericFile: String = "error.html"): Interceptor { 9 | override fun intercept(request: Request, response: Response): Boolean { 10 | val path = folder.trim('/') 11 | var fileToServe = "${path}/${response.statusCode}.${fileExtensions}" 12 | val file = File(fileToServe) 13 | if (!file.exists()) { 14 | fileToServe = "${path}/$fallbackGenericFile" 15 | } 16 | response.sendFile(fileToServe) 17 | 18 | return false 19 | } 20 | } 21 | 22 | fun AppServer.serveErrorsFromFolder(folder: String, fileExtensions: String = "html", fallbackGenericFile: String = "error.html") { 23 | intercept(FileBasedErrorInterceptor(folder, fileExtensions, fallbackGenericFile), "*", InterceptOn.Error) 24 | } 25 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/websocket/WebSocketProtocolHandler.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.websocket 2 | 3 | import io.netty.channel.ChannelHandlerContext 4 | import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler 5 | import org.wasabifx.wasabi.events.connectionInactive 6 | 7 | class WebSocketProtocolHandler(val uri:String?, val sub: String?, val allowExtensions: Boolean): WebSocketServerProtocolHandler(uri, sub, allowExtensions) { 8 | 9 | override fun userEventTriggered(ctx: ChannelHandlerContext?, evt: Any?) { 10 | 11 | // If handshake is complete add new channel to list of client connections to the channel for broadcast. 12 | if(evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) { 13 | channelClients[uri]!!.add(ctx!!.channel()) 14 | } 15 | 16 | super.userEventTriggered(ctx, evt) 17 | } 18 | 19 | override fun channelInactive(ctx: ChannelHandlerContext?) { 20 | val channel = ctx!!.channel() 21 | channelClients[uri]!!.remove(channel) 22 | connectionInactive(channel) 23 | super.channelInactive(ctx) 24 | } 25 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/ResponseSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.protocol.http.Cookie 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertTrue 6 | import org.junit.Test as spec 7 | 8 | class ResponseSpecs: TestServerContext() { 9 | 10 | @spec fun cookies_must_be_encoded_with_ServerCookieEncoder() { 11 | TestServer.reset() 12 | TestServer.appServer.get("/test", { 13 | val cookie = Cookie("testing", "lipsum") 14 | cookie.setPath("/test") 15 | cookie.setDomain("localhost") 16 | cookie.isSecure = true 17 | cookie.isHttpOnly = true 18 | response.setCookie(cookie) 19 | response.send("test", "plain/text") 20 | }) 21 | 22 | val response = TestClient(TestServer.appServer).sendSimpleRequest("/test", "GET") 23 | val cookieHeaders = response.headers.filter { it.name == "Set-Cookie" } 24 | assertTrue(cookieHeaders.count() > 0) 25 | 26 | val cookieString = cookieHeaders.first().value 27 | 28 | assertEquals("testing=lipsum; Path=/test; Domain=localhost; Secure; HTTPOnly", cookieString) 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/websocket/WebSocketFrameHandler.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.websocket 2 | 3 | import io.netty.channel.ChannelHandlerContext 4 | import io.netty.channel.SimpleChannelInboundHandler 5 | import io.netty.handler.codec.http.websocketx.WebSocketFrame 6 | import org.slf4j.LoggerFactory 7 | 8 | class WebSocketFrameHandler(val handler: ChannelHandler.() -> Unit): SimpleChannelInboundHandler() { 9 | 10 | private var log = LoggerFactory.getLogger(WebSocketFrameHandler::class.java) 11 | 12 | override fun channelRead0(ctx: ChannelHandlerContext?, msg: WebSocketFrame?) { 13 | 14 | // Init a new wrapper for the current frame. 15 | val response = Response() 16 | 17 | // Grab the handler for the current channel. 18 | val channelExtension : ChannelHandler.() -> Unit = handler 19 | val channelHandler = ChannelHandler(ctx, msg!!, response) 20 | channelHandler.channelExtension() 21 | } 22 | 23 | override fun channelInactive(ctx: ChannelHandlerContext?) { 24 | 25 | // TODO work out the best way to fire something here to notify client has disconnected. 26 | 27 | super.channelInactive(ctx) 28 | } 29 | } -------------------------------------------------------------------------------- /wasabi-samples/src/main/kotlin/org/wasabifx/wasabi/samples/websocket.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.samples 2 | 3 | import io.netty.handler.codec.http.websocketx.TextWebSocketFrame 4 | import org.wasabifx.wasabi.app.AppConfiguration 5 | import org.wasabifx.wasabi.app.AppServer 6 | import org.wasabifx.wasabi.events.connectionInactive 7 | import org.wasabifx.wasabi.protocol.websocket.broadcast 8 | import org.wasabifx.wasabi.protocol.websocket.respond 9 | 10 | fun main(args: Array) { 11 | 12 | val server = AppServer(AppConfiguration(enableLogging = false)) 13 | 14 | // This is currently fired when a websocket client becomes inactive. 15 | connectionInactive += { print("Client disconnected!\n")} 16 | 17 | server.channel("/whoop", { 18 | 19 | // Directly responds to the client with "WHOOP!" text frame acknowledgement ( yes not very useful! ) 20 | respond(ctx!!.channel(), TextWebSocketFrame("WHOOP!")) 21 | }) 22 | 23 | server.channel("/broadcast", { 24 | if(frame is TextWebSocketFrame) { 25 | 26 | // This will broadcast to all clients connected to the current channel 27 | broadcast("/broadcast", frame) 28 | } 29 | }) 30 | 31 | server.start() 32 | } 33 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http2/Http2HandlerBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http2 2 | 3 | import io.netty.handler.codec.http2.* 4 | import io.netty.handler.logging.LogLevel.INFO 5 | import org.slf4j.LoggerFactory 6 | import org.wasabifx.wasabi.app.AppServer 7 | 8 | class Http2HandlerBuilder(val appServer: AppServer) : AbstractHttp2ConnectionHandlerBuilder() { 9 | 10 | private val frameLogger: Http2FrameLogger = Http2FrameLogger(INFO, Http2Handler::class.java) 11 | private val logger = LoggerFactory.getLogger(Http2HandlerBuilder::class.java) 12 | 13 | 14 | init { 15 | logger.info("http2 handler builder init") 16 | frameLogger(frameLogger) 17 | } 18 | 19 | public override fun build(): Http2Handler? { 20 | logger.info("noarg build") 21 | return super.build() 22 | } 23 | 24 | public override fun build(decoder: Http2ConnectionDecoder?, encoder: Http2ConnectionEncoder?, initialSettings: Http2Settings?): Http2Handler? { 25 | logger.info("arg build") 26 | val handler = Http2Handler(appServer, decoder!!, encoder!!, initialSettings!!) 27 | frameListener(handler) 28 | return handler 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/ETagInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.app.AppServer 4 | import org.wasabifx.wasabi.protocol.http.Request 5 | import org.wasabifx.wasabi.protocol.http.Response 6 | 7 | class ETagInterceptor(private val objectTagFunc: (Any) -> String = { obj -> obj.hashCode().toString() }): Interceptor { 8 | override fun intercept(request: Request, response: Response): Boolean { 9 | var executeNext = false 10 | if (response.sendBuffer != null) { 11 | val objectTag = objectTagFunc(response.sendBuffer!!) 12 | val incomingETag = request.ifNoneMatch 13 | if (incomingETag.compareTo(objectTag, ignoreCase = true) == 0) { 14 | response.setStatus(304, "Not modified") 15 | } else { 16 | response.etag = objectTag 17 | executeNext = true 18 | } 19 | } else { 20 | executeNext = true 21 | } 22 | return executeNext 23 | } 24 | } 25 | 26 | fun AppServer.enableETag(path: String = "*", objectTagFunc: (Any) -> String = { obj -> obj.hashCode().toString() }) { 27 | intercept(ETagInterceptor(objectTagFunc), path, interceptOn = InterceptOn.PostExecution) 28 | } -------------------------------------------------------------------------------- /wasabi-core/tools/sample.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | // Keeps in sync with current development snapshots, good idea following current wasabi. 3 | project.ext.kotlin_version = '0.1-SNAPSHOT' 4 | repositories { 5 | mavenCentral() 6 | maven { 7 | url "https://oss.sonatype.org/content/repositories/snapshots" 8 | } 9 | } 10 | dependencies { 11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 12 | } 13 | } 14 | 15 | apply plugin: 'kotlin' 16 | apply plugin: 'application' 17 | 18 | mainClassName = '// TODO: Fill in {domain.Packagename}Package' 19 | 20 | repositories { 21 | mavenCentral() 22 | maven { 23 | url "https://oss.sonatype.org/content/repositories/snapshots" 24 | } 25 | maven { 26 | url "http://repository.jetbrains.com/all" 27 | } 28 | } 29 | 30 | dependencies { 31 | compile "org.wasabifx:wasabi:0.1-SNAPSHOT" 32 | compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 33 | 34 | } 35 | 36 | sourceSets { 37 | src { 38 | main { 39 | kotlin 40 | } 41 | } 42 | test { 43 | main { 44 | kotlin 45 | } 46 | } 47 | main.java.srcDirs += 'src/main/kotlin' 48 | } 49 | 50 | task wrapper(type: Wrapper) { 51 | gradleVersion = '2.5' 52 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/AutoLocationInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.protocol.http.StatusCodes 4 | import org.wasabifx.wasabi.interceptors.enableAutoLocation 5 | import kotlin.test.assertEquals 6 | import org.junit.Test as spec 7 | 8 | 9 | class AutoLocationInterceptorSpecs : TestServerContext() { 10 | @spec fun with_auto_location_interceptor_enabled_when_setting_response_as_created_and_resourceId_it_should_return_location_on_post () { 11 | val headers = hashMapOf( 12 | "User-Agent" to "test-client", 13 | "Cache-Control" to "max-age=0", 14 | "Accept" to "text/html,application/xhtml+xml,application/xml", 15 | "Accept-Encoding" to "gzip,deflate,sdch", 16 | "Accept-Language" to "en-US,en;q=0.8", 17 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*" 18 | ) 19 | TestServer.appServer.enableAutoLocation() 20 | TestServer.appServer.post("/person", { 21 | response.resourceId = "20" 22 | response.setStatus(StatusCodes.Created) 23 | }) 24 | val response = postForm("http://localhost:${TestServer.definedPort}/person", headers, arrayListOf()) 25 | assertEquals("http://localhost:${TestServer.definedPort}/person/20", response.headers.filter { it.name == "Location" }.firstOrNull()?.value) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/AutoOptionsInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import org.wasabifx.wasabi.app.AppServer 5 | import org.wasabifx.wasabi.protocol.http.Request 6 | import org.wasabifx.wasabi.protocol.http.Response 7 | import org.wasabifx.wasabi.protocol.http.StatusCodes 8 | import org.wasabifx.wasabi.routing.PatternAndVerbMatchingRouteLocator 9 | import org.wasabifx.wasabi.routing.Route 10 | 11 | class AutoOptionsInterceptor(val routes: Set): Interceptor { 12 | override fun intercept(request: Request, response: Response): Boolean { 13 | var executeNext = false 14 | if (request.method == HttpMethod.OPTIONS) { 15 | val routeLocator = PatternAndVerbMatchingRouteLocator(routes) 16 | 17 | val allowedMethods = routes 18 | .filter { routeLocator.compareRouteSegments(it, request.path) } 19 | .map { it.method } 20 | .toTypedArray() 21 | 22 | response.setAllowedMethods(allowedMethods) 23 | response.setStatus(StatusCodes.OK) 24 | } else { 25 | executeNext = true 26 | } 27 | 28 | return executeNext 29 | } 30 | } 31 | 32 | fun AppServer.enableAutoOptions() { 33 | intercept(AutoOptionsInterceptor(routes)) 34 | } 35 | fun AppServer.disableAutoOptions() { 36 | this.interceptors.removeAll { it.interceptor is AutoOptionsInterceptor } 37 | } 38 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/FileUploadSpec.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import okhttp3.* 4 | import org.wasabifx.wasabi.app.FileUpload 5 | import org.wasabifx.wasabi.protocol.http.StatusCodes 6 | import java.io.File 7 | import org.junit.Test as spec 8 | 9 | class FileUploadSpec: TestServerContext() { 10 | @spec fun fileupload_works() { 11 | val testTitle = "test title" 12 | TestServer.reset() 13 | TestServer.appServer.post("/fileupload", { 14 | if((request.bodyParams["file"] is FileUpload) && 15 | (request.bodyParams["title"].toString() == testTitle)) { 16 | response.setStatus(StatusCodes.OK) 17 | } else { 18 | response.setStatus(StatusCodes.BadRequest) 19 | } 20 | }) 21 | 22 | val client = OkHttpClient() 23 | val uploadFile = File(javaClass.classLoader.getResource("fileupload-test.txt").toURI()) 24 | val body = MultipartBody.Builder() 25 | .setType(MultipartBody.FORM) 26 | .addFormDataPart("title", testTitle) 27 | .addFormDataPart("file", "file.txt", RequestBody.create(MediaType.parse("text/plain"), uploadFile)) 28 | .build() 29 | 30 | val request = Request.Builder() 31 | .url("http://localhost:${TestServer.definedPort}/fileupload") 32 | .post(body) 33 | .build() 34 | 35 | val response = client.newCall(request).execute() 36 | assert(response.isSuccessful) 37 | } 38 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/FileBasedErrorInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import kotlin.test.assertEquals 5 | import org.wasabifx.wasabi.interceptors.serveErrorsFromFolder 6 | import org.wasabifx.wasabi.protocol.http.StatusCodes 7 | import java.io.File 8 | 9 | class FileBasedErrorInterceptorSpecs : TestServerContext() { 10 | 11 | @spec fun when_an_error_occurs_and_corresponding_error_file_exists_it_should_serve_it() { 12 | 13 | TestServer.reset() 14 | TestServer.appServer.serveErrorsFromFolder("testData${File.separatorChar}public") 15 | 16 | val response = get("http://localhost:${TestServer.definedPort}/notvalid", hashMapOf()) 17 | 18 | assertEquals(404, response.statusCode) 19 | assertEquals("Custom File 404", response.body) 20 | } 21 | 22 | @spec fun when_an_error_occurs_and_corresponding_error_file_does_not_exist_it_should_serve_default_error_file() { 23 | 24 | TestServer.reset() 25 | TestServer.appServer.serveErrorsFromFolder("testData${File.separatorChar}public") 26 | 27 | TestServer.appServer.get("/notvalid", { response.setStatus(StatusCodes.Forbidden)}) 28 | val response = get("http://localhost:${TestServer.definedPort}/notvalid", hashMapOf()) 29 | 30 | assertEquals(403, response.statusCode) 31 | assertEquals("Standard Error File", response.body) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/BasicAuthenticationInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.authentication.BasicAuthentication 4 | import org.wasabifx.wasabi.interceptors.AuthenticationInterceptor 5 | import org.wasabifx.wasabi.interceptors.useAuthentication 6 | import kotlin.test.assertEquals 7 | import org.junit.Test as spec 8 | 9 | class BasicAuthenticationInterceptorSpecs : TestServerContext() { 10 | 11 | @spec fun requesting_a_protected_resource_should_return_authentication_required () { 12 | 13 | TestServer.appServer.useAuthentication(BasicAuthentication("protected", { user, pass -> user == pass }), "/protected") 14 | 15 | TestServer.appServer.get("/protected", { response.send("This should be protected")}) 16 | 17 | val response = get("http://localhost:${TestServer.definedPort}/protected", hashMapOf()) 18 | 19 | assertEquals(401, response.statusCode) 20 | } 21 | 22 | @spec fun requesting_a_unprotected_resource_should_return_success () { 23 | 24 | TestServer.appServer.useAuthentication(BasicAuthentication("protected", { user, pass -> user == pass }),"/protected") 25 | 26 | TestServer.appServer.get("/protected", { response.send("This should be proctected")}) 27 | 28 | TestServer.appServer.get("/notprotected", { response.send("This should not be protected")}) 29 | 30 | val response = get("http://localhost:${TestServer.definedPort}/notprotected", hashMapOf()) 31 | 32 | assertEquals(200, response.statusCode) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/ContentNegotiationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.protocol.http.Request 4 | import org.wasabifx.wasabi.protocol.http.Response 5 | import org.wasabifx.wasabi.serializers.Serializer 6 | import org.wasabifx.wasabi.app.AppServer 7 | import org.wasabifx.wasabi.protocol.http.StatusCodes 8 | 9 | 10 | class ContentNegotiationInterceptor(val serializers: List): Interceptor { 11 | override fun intercept(request: Request, response: Response): Boolean { 12 | var executeNext = false 13 | if (response.negotiatedMediaType == "" && (response.sendBuffer != null) && !(response.sendBuffer is String)) { 14 | for (requestedContentType in response.requestedContentTypes) { 15 | val serializer = serializers.firstOrNull { it.canSerialize(requestedContentType) } 16 | if (serializer != null) { 17 | response.negotiatedMediaType = requestedContentType 18 | executeNext = true 19 | break 20 | } 21 | } 22 | if (response.negotiatedMediaType == "") { 23 | response.setStatus(StatusCodes.UnsupportedMediaType) 24 | } 25 | } else { 26 | executeNext = true 27 | } 28 | 29 | return executeNext 30 | } 31 | } 32 | 33 | fun AppServer.enableContentNegotiation() { 34 | intercept(ContentNegotiationInterceptor(serializers), "*", InterceptOn.PostExecution) 35 | } 36 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/PatternAndVerbMatchingRouteLocator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | 5 | 6 | class PatternAndVerbMatchingRouteLocator(val routes: Set) : RouteLocator { 7 | 8 | // TODO: Make creating segments part of start-up. 9 | override fun compareRouteSegments(route1: Route, path: String): Boolean { 10 | val segments1 = route1.path.split('/') 11 | val segments2 = path.split('/') 12 | if (segments1.size != segments2.size) { 13 | return false 14 | } 15 | var i = 0 16 | for (segment in segments1) { 17 | if (!segment.startsWith(':') && segment.compareTo(segments2[i], ignoreCase = true) != 0) { 18 | return false 19 | } 20 | i++ 21 | } 22 | return true 23 | } 24 | 25 | override fun findRouteHandlers(path: String, method: HttpMethod): Route { 26 | val matchingPaths = routes.filter { compareRouteSegments(it, path) } 27 | if (matchingPaths.count() == 0) { 28 | throw RouteNotFoundException() 29 | } 30 | 31 | val matchingVerbs = (matchingPaths.filter { it.method == method }) 32 | 33 | if (matchingVerbs.count() > 0) { 34 | val matchedRoute = if (matchingVerbs.count() == 1) matchingVerbs.first() 35 | else matchingVerbs.firstOrNull { it.path == path } 36 | return matchedRoute ?: matchingVerbs.findMostWeightyBy(path)!! 37 | } 38 | val methods = arrayOf() // TODO: This needs to be filled 39 | throw InvalidMethodException(allowedMethods = methods) 40 | } 41 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/core/NettyPipelineInitializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.core 2 | 3 | import io.netty.channel.ChannelInitializer 4 | import io.netty.channel.socket.SocketChannel 5 | import io.netty.handler.codec.http.HttpObjectAggregator 6 | import io.netty.handler.codec.http.HttpRequestDecoder 7 | import io.netty.handler.codec.http.HttpResponseEncoder 8 | import io.netty.handler.ssl.SslContext 9 | import org.slf4j.LoggerFactory 10 | import org.wasabifx.wasabi.app.AppServer 11 | import org.wasabifx.wasabi.app.configuration 12 | import org.wasabifx.wasabi.protocol.http.ProtocolNegotiator 13 | 14 | class NettyPipelineInitializer(private val appServer: AppServer, private val sslContext: SslContext?): 15 | ChannelInitializer() { 16 | 17 | private val logger = LoggerFactory.getLogger(NettyPipelineInitializer::class.java) 18 | 19 | override fun initChannel(channel: SocketChannel) { 20 | if (sslContext != null) initSslChannel(channel) else initBasicChannel(channel) 21 | } 22 | 23 | private fun initSslChannel(ch: SocketChannel) { 24 | ch.pipeline().addLast(sslContext!!.newHandler(ch.alloc()), ProtocolNegotiator(appServer)) 25 | } 26 | 27 | private fun initBasicChannel(ch: SocketChannel) { 28 | logger.debug("Initialising initial Wasabi pipeline") 29 | val pipeline = ch.pipeline() 30 | pipeline.addLast("decoder", HttpRequestDecoder()) 31 | pipeline.addLast("encoder", HttpResponseEncoder()) 32 | pipeline.addLast("aggregator", HttpObjectAggregator(configuration!!.maxHttpContentLength)) 33 | pipeline.addLast("handler", HttpPipelineInitializer(appServer)) 34 | } 35 | } 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/StatusCodes.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | enum class StatusCodes(val code: Int) { 4 | 5 | // 1XX 6 | Continue(100), 7 | SwitchingProtocols(101), 8 | Processing(102), 9 | // 2XX 10 | 11 | OK(200), 12 | Created(201), 13 | Accepted(202), 14 | NonAuthoritativeInformation(203), 15 | NoContent(204), 16 | ResetContent(205), 17 | PartialContent(206), 18 | 19 | // 3XX 20 | MultipleChoices(300), 21 | MovedPermanently(301), 22 | Found(302), 23 | SeeOther(303), 24 | NotModified(304), 25 | UseProxy(305), 26 | SwitchProxy(306), 27 | TemporaryRedirect(307), 28 | PermanentRedirect(308), 29 | 30 | // 4XX 31 | BadRequest(400), 32 | Unauthorized(401), 33 | PaymentRequired(402), 34 | Forbidden(403), 35 | NotFound(404), 36 | MethodNotAllowed(405), 37 | NotAcceptable(406), 38 | ProxyAuthenticationRequired(407), 39 | RequestTimeout(408), 40 | Conflict(409), 41 | Gone(410), 42 | LengthRequired(411), 43 | PreconditionFailed(412), 44 | RequestEntityTooLarge(413), 45 | RequestURITooLarge(414) { 46 | override val description: String = "Request-URI Too Large" 47 | }, 48 | UnsupportedMediaType(415), 49 | RequestedRageNotSatisfiable(416), 50 | ExceptionFailed(417), 51 | TooManyRequests(429), 52 | RequestHeaderFieldTooLarge(431), 53 | 54 | // 5XX 55 | InternalServerError(500), 56 | NotImplemented(501), 57 | BadGateway(502), 58 | ServiceUnavailable(503), 59 | GatewayTimeout(504), 60 | VersionNotSupported(505), 61 | VariantAlsoNegotiates(506), 62 | InsufficientStorage(507), 63 | BandwidthLimitExceeded(509); 64 | 65 | open val description: String 66 | get() = this.toString().replace("([A-Z])".toRegex(), " $1") 67 | 68 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/UrlRequestingSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import kotlin.test.assertEquals 4 | import org.junit.Test as spec 5 | 6 | class UrlRequestingSpecs: TestServerContext() { 7 | 8 | 9 | val headers = hashMapOf( 10 | "User-Agent" to "test-client", 11 | "Cache-Control" to "max-age=0", 12 | "Accept" to "text/html,application/xhtml+xml,application/xml", 13 | "Accept-Encoding" to "gzip,deflate,sdch", 14 | "Accept-Language" to "en-US,en;q=0.8", 15 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*;q=0.3" 16 | 17 | ) 18 | 19 | @spec fun a_get_on_an_existing_resource_should_return_it() { 20 | 21 | TestServer.appServer.get("/hello", { response.send("Hello")}) 22 | 23 | 24 | val response = get("http://localhost:${TestServer.definedPort}/hello", headers) 25 | 26 | assertEquals("Hello", response.body) 27 | 28 | } 29 | 30 | @spec fun a_get_on_an_non_existing_resource_should_return_a_404_with_message_Not_Found() { 31 | 32 | 33 | val response = get("http://localhost:${TestServer.definedPort}/nothing", headers) 34 | 35 | assertEquals(404, response.statusCode) 36 | assertEquals("Not Found", response.statusDescription) 37 | 38 | } 39 | 40 | @spec fun a_get_on_an_existing_resource_with_invalid_verb_should_return_405_with_message_method_not_allowed_and_header_of_allowed_methods() { 41 | 42 | TestServer.appServer.get("/hello", { response.send("Hello")}) 43 | 44 | val response = delete("http://localhost:${TestServer.definedPort}/hello", headers) 45 | 46 | assertEquals(405, response.statusCode) 47 | assertEquals("Method Not Allowed", response.statusDescription) 48 | // TODO: Fix headers on client side .. (get helper) 49 | //assertEquals("Allow: GET", headers["Allow"]) 50 | } 51 | 52 | } 53 | 54 | 55 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/configuration/ConfigurationStorage.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.configuration 2 | 3 | import com.fasterxml.jackson.core.JsonParseException 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException 6 | import org.wasabifx.wasabi.app.AppConfiguration 7 | import java.io.File 8 | 9 | class ConfigurationStorage { 10 | 11 | fun loadProduction(): AppConfiguration { 12 | return loadFromFile("production.json") 13 | } 14 | 15 | fun loadDebug(): AppConfiguration { 16 | return loadFromFile("debug.json") 17 | } 18 | 19 | fun loadFromFile(jsonFilename: String): AppConfiguration { 20 | val objectMapper = ObjectMapper() 21 | val jsonFile = File(jsonFilename) 22 | if (jsonFile.exists()) { 23 | try { 24 | val configuration = objectMapper.readValue(jsonFile, AppConfiguration::class.java) 25 | if (configuration != null) { 26 | return configuration 27 | } else { 28 | throw InvalidConfigurationException("Could not read configuration") 29 | } 30 | } catch (exception: UnrecognizedPropertyException) { 31 | throw InvalidConfigurationException("Invalid property in configuration file: " + exception.message) 32 | } catch (exception: JsonParseException) { 33 | throw InvalidConfigurationException("Invalid JSON in configuration file: " + exception.location) 34 | } 35 | } else { 36 | throw InvalidConfigurationException("Configuration file does not exist") 37 | } 38 | } 39 | 40 | fun saveToFile(configuration: AppConfiguration, jsonFilename: String) { 41 | val objectMapper = ObjectMapper() 42 | objectMapper.writeValue(File(jsonFilename), configuration) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/ConfigSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.app.AppConfiguration 4 | import org.wasabifx.wasabi.app.AppServer 5 | import org.junit.Test as spec 6 | import kotlin.test.assertEquals 7 | import java.net.Socket 8 | import java.net.SocketAddress 9 | import java.net.InetSocketAddress 10 | 11 | class ConfigSpecs { 12 | 13 | @spec fun creating_an_app_server_without_explicit_configuration_should_use_default_debug_configuration() { 14 | val appServer = AppServer() 15 | 16 | assertEquals(3000, appServer.configuration.port) 17 | assertEquals(null, appServer.configuration.hostname) 18 | 19 | assertEquals("Server starting on port 3000", appServer.configuration.welcomeMessage) 20 | assertEquals(true, appServer.configuration.enableLogging) 21 | } 22 | 23 | @spec fun creating_an_app_server_with_explicit_configuration_should_use_the_configuration_specified() { 24 | 25 | 26 | val appServer = AppServer( 27 | AppConfiguration( 28 | port = 5000, 29 | welcomeMessage = "Hello there!", 30 | enableLogging = false)) 31 | 32 | assertEquals(5000, appServer.configuration.port) 33 | assertEquals("Hello there!", appServer.configuration.welcomeMessage) 34 | assertEquals(false, appServer.configuration.enableLogging) 35 | } 36 | 37 | @spec fun specifying_hostname_changes_default_welcome_message() { 38 | val appServer = AppServer( 39 | AppConfiguration( 40 | port = 5000, 41 | hostname = "127.0.0.1")) 42 | 43 | assertEquals(5000, appServer.configuration.port) 44 | assertEquals("127.0.0.1", appServer.configuration.hostname) 45 | 46 | assertEquals("Server starting at 127.0.0.1:5000", appServer.configuration.welcomeMessage) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/routing/Routes.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.routing 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | 5 | /** 6 | * @author Tradunsky V.V. 7 | */ 8 | fun Iterable.findMostWeightyBy(path: String): Route? { 9 | val segments = path.split("/") 10 | val routeWeights = mutableListOf>() 11 | for (route in this) { 12 | val routeSegments = route.path.split("/") 13 | if (routeSegments.size != segments.size) continue 14 | var routeWeight = 0.0 15 | segments.forEachIndexed { index, segment -> 16 | if (segment.startsWith(':')) { 17 | routeWeight += 0.5 18 | } else if (segment.compareTo(routeSegments[index], ignoreCase = true) == 0) { 19 | ++routeWeight 20 | } 21 | } 22 | routeWeights.add(Pair(routeWeight, route)) 23 | } 24 | return routeWeights.maxBy { it.first }?.second 25 | } 26 | 27 | fun Set.findSimilar(method: HttpMethod, path: String): Route? { 28 | val segments = path.split("/") 29 | for (route in this) { 30 | val currentSegments = route.path.split("/") 31 | if (segments.size != currentSegments.size || route.method != method) continue 32 | 33 | if (areSegmentsEqual(segments, currentSegments)) return route 34 | } 35 | return null 36 | } 37 | 38 | private fun areSegmentsEqual(segments: List, otherSegments: List): Boolean { 39 | var index = 0 40 | for (segment in segments){ 41 | val anotherSegment = otherSegments[index] 42 | if (segment.startsWith(":")) { 43 | if (!anotherSegment.startsWith(":")) return false 44 | }else if (anotherSegment.startsWith(":")) { 45 | if (!segment.startsWith(":")) return false 46 | } else if (segment.compareTo(anotherSegment, ignoreCase = true) != 0) return false 47 | ++index 48 | } 49 | return true 50 | } 51 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/SerializerSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import org.wasabifx.wasabi.deserializers.JsonDeserializer 5 | import org.wasabifx.wasabi.deserializers.MultiPartFormDataDeserializer 6 | import org.wasabifx.wasabi.serializers.JsonSerializer 7 | import org.wasabifx.wasabi.serializers.XmlSerializer 8 | import kotlin.test.assertEquals 9 | 10 | class SerializerSpecs { 11 | 12 | @spec fun canSerialize_should_return_true_when_given_a_media_type_that_serializer_can_process() { 13 | 14 | val jsonSerializer = JsonSerializer() 15 | val xmlSerializer = XmlSerializer() 16 | 17 | assertEquals(true, jsonSerializer.canSerialize("application/json")) 18 | assertEquals(true, jsonSerializer.canSerialize("application/vnd.wasabifx+json")) 19 | assertEquals(false, jsonSerializer.canSerialize("application/vnd.wasabifx+xml")) 20 | assertEquals(true, xmlSerializer.canSerialize("application/vnd.wasabifx+xml")) 21 | assertEquals(true, xmlSerializer.canSerialize("application/xml")) 22 | assertEquals(false, xmlSerializer.canSerialize("application/vnd.wasabifx+json")) 23 | 24 | } 25 | 26 | @spec fun canDeserialize_should_return_true_when_given_a_media_type_that_deserializer_can_process() { 27 | 28 | val jsonDeserializer = JsonDeserializer() 29 | val mpDeserializer = MultiPartFormDataDeserializer() 30 | 31 | assertEquals(true, jsonDeserializer.canDeserialize("application/json")) 32 | assertEquals(false, jsonDeserializer.canDeserialize("application/json-v2")) 33 | assertEquals(true, mpDeserializer.canDeserialize("application/x-www-form-urlencoded")) 34 | assertEquals(true, mpDeserializer.canDeserialize("application/X-WWW-Form-Urlencoded; charset=ISO-8859-1")) 35 | assertEquals(false, mpDeserializer.canDeserialize("application/x-www-form-urlencoded-v2; charset=utf-8")) 36 | 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/SessionManagementInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.app.AppServer 4 | import org.wasabifx.wasabi.protocol.http.Cookie 5 | import org.wasabifx.wasabi.protocol.http.Request 6 | import org.wasabifx.wasabi.protocol.http.Response 7 | import org.wasabifx.wasabi.protocol.http.Session 8 | import org.wasabifx.wasabi.storage.InMemorySessionStorage 9 | import java.util.* 10 | 11 | class SessionManagementInterceptor(val cookieKey: String = "_sessionID", sessionStorage: SessionStorage = InMemorySessionStorage()): Interceptor, SessionStorage by sessionStorage { 12 | 13 | private fun generateSessionID(): String { 14 | return UUID.randomUUID().toString() 15 | } 16 | 17 | override fun intercept(request: Request, response: Response): Boolean { 18 | 19 | // try loading the session 20 | if (request.cookies.containsKey(cookieKey)) { 21 | val cookie = request.cookies.get(cookieKey) 22 | if (cookie is Cookie) { 23 | if (cookie.value() != "") { 24 | request.session = loadSession(cookie.value()) 25 | request.session?.let { 26 | it.extendSession() 27 | } 28 | } 29 | } 30 | } 31 | 32 | // ensure a session is there 33 | val session = request.session ?: Session(generateSessionID()).apply { 34 | storeSession(this) 35 | request.session = this 36 | } 37 | 38 | // send the cookie 39 | val tmpCookie = Cookie(cookieKey, session.id) 40 | tmpCookie.setDomain(request.host) 41 | tmpCookie.isSecure = request.isSecure 42 | tmpCookie.setPath("/") 43 | 44 | response.setCookie(tmpCookie) 45 | 46 | return true 47 | } 48 | } 49 | 50 | fun AppServer.enableSessionSupport() { 51 | intercept(SessionManagementInterceptor()) 52 | } 53 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/StaticFileInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import org.wasabifx.wasabi.app.AppServer 5 | import org.wasabifx.wasabi.protocol.http.Request 6 | import org.wasabifx.wasabi.protocol.http.Response 7 | import java.io.File 8 | import java.net.URLDecoder 9 | 10 | 11 | class StaticFileInterceptor(val folder: String, val useDefaultFile: Boolean = false, val defaultFile: String = "index.html") : Interceptor { 12 | 13 | private val absoluteFolder: String = File(folder).canonicalPath.toString() 14 | 15 | override fun intercept(request: Request, response: Response): Boolean { 16 | var executeNext = false 17 | 18 | if (request.method == HttpMethod.GET) { 19 | 20 | val uriPath = URLDecoder.decode( 21 | if( request.uri.contains("?") ) request.uri.substringBefore("?") else request.uri, 22 | Charsets.UTF_8.toString() 23 | ) 24 | 25 | val fullPath = "${absoluteFolder}${uriPath}" 26 | val file = File(fullPath) 27 | 28 | if (!file.canonicalPath.startsWith(absoluteFolder)) { 29 | throw RuntimeException("Attempt to open file outside of static file folder") 30 | } 31 | 32 | when { 33 | file.exists() && file.isFile -> response.sendFile(fullPath) 34 | file.exists() && file.isDirectory && useDefaultFile -> response.sendFile("${fullPath}/${defaultFile}") 35 | else -> executeNext = true 36 | } 37 | } else { 38 | executeNext = true 39 | } 40 | return executeNext 41 | } 42 | } 43 | 44 | fun AppServer.serveStaticFilesFromFolder(folder: String, useDefaultFile: Boolean = false, defaultFile: String = "index.html") { 45 | val staticInterceptor = StaticFileInterceptor(folder, useDefaultFile, defaultFile) 46 | intercept(staticInterceptor) 47 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/deserializers/MultiPartFormDataDeserializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.deserializers 2 | 3 | import io.netty.handler.codec.http.multipart.InterfaceHttpData 4 | import io.netty.handler.codec.http.multipart.InterfaceHttpData.HttpDataType 5 | import io.netty.handler.codec.http.multipart.Attribute 6 | import io.netty.handler.codec.http.multipart.FileUpload 7 | import java.util.HashMap 8 | 9 | class MultiPartFormDataDeserializer: Deserializer("application/x-www-form-urlencoded", "multipart/form-data") { 10 | 11 | // Not much more we can do here without uglyness, if its not a 12 | // List we should let the exception bubble and 13 | // correctly return a 500 as something bad has happened... 14 | @Suppress("UNCHECKED_CAST") 15 | override fun deserialize(input: Any): HashMap { 16 | val bodyParams = HashMap() 17 | parseBodyParams(input as List, bodyParams) 18 | return bodyParams 19 | } 20 | 21 | private fun parseBodyParams(httpDataList: List, bodyParams: HashMap) { 22 | for(entry in httpDataList) { 23 | addBodyParam(entry, bodyParams) 24 | } 25 | } 26 | 27 | private fun addBodyParam(httpData: InterfaceHttpData, bodyParams: HashMap) { 28 | when (httpData.httpDataType) { 29 | HttpDataType.Attribute -> { 30 | val attribute = httpData as Attribute 31 | bodyParams[attribute.name] = attribute.value 32 | } 33 | HttpDataType.FileUpload -> { 34 | val upload = httpData as FileUpload 35 | bodyParams[upload.name] = org.wasabifx.wasabi.app.FileUpload(upload.filename, upload.get()) 36 | } 37 | HttpDataType.InternalAttribute -> { 38 | // TODO: Add support for other types of attributes (namely InternalAttribute) 39 | } 40 | } 41 | } 42 | 43 | 44 | 45 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/AutoOptionsInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import org.wasabifx.wasabi.interceptors.enableAutoOptions 5 | import kotlin.test.assertEquals 6 | import org.wasabifx.wasabi.protocol.http.StatusCodes 7 | import org.wasabifx.wasabi.interceptors.disableAutoOptions 8 | import org.wasabifx.wasabi.interceptors.AutoOptionsInterceptor 9 | import org.junit.Ignore 10 | 11 | class AutoOptionsInterceptorSpecs : TestServerContext() { 12 | 13 | @spec fun testing_auto_options_shutdown () { 14 | TestServer.appServer.enableAutoOptions() 15 | 16 | assertEquals(1, TestServer.appServer.interceptors.count { it.interceptor is AutoOptionsInterceptor }) 17 | 18 | TestServer.appServer.disableAutoOptions() 19 | 20 | assertEquals(0, TestServer.appServer.interceptors.count { it.interceptor is AutoOptionsInterceptor }) 21 | 22 | } 23 | 24 | @spec fun auto_options_should_return_all_methods_available_for_a_specific_resource () { 25 | TestServer.appServer.get("/person", {}) 26 | TestServer.appServer.post("/person", {}) 27 | TestServer.appServer.post("/customer", {}) 28 | TestServer.appServer.enableAutoOptions() 29 | 30 | val response = options("http://localhost:${TestServer.definedPort}/person") 31 | 32 | val allowHeader = response.headers.filter { it.name == "Allow"}.first() 33 | 34 | assertEquals("GET,POST", allowHeader.value) 35 | 36 | TestServer.appServer.disableAutoOptions() 37 | } 38 | 39 | @Ignore 40 | @spec fun with_auto_options_disabled_options_should_return_method_not_allowed () { 41 | 42 | TestServer.appServer.get("/person", {}) 43 | TestServer.appServer.enableAutoOptions() 44 | 45 | val response = options("http://localhost:${TestServer.definedPort}/person") 46 | 47 | assertEquals(StatusCodes.MethodNotAllowed.code, response.statusCode) 48 | 49 | TestServer.appServer.disableAutoOptions() 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/ExceptionHandlerSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.protocol.http.StatusCodes 4 | import org.wasabifx.wasabi.routing.exceptionHandler 5 | import kotlin.test.assertEquals 6 | import kotlin.test.assertTrue 7 | import org.junit.Test as spec 8 | 9 | /** 10 | * @author Tradunsky V.V. 11 | */ 12 | class ExceptionHandlerSpecs { 13 | 14 | @spec fun should_be_default_exception_handler() { 15 | TestServer.reset() 16 | 17 | assertTrue(TestServer.appServer.exceptionHandlers.isNotEmpty()) 18 | } 19 | 20 | @spec fun adding_the_default_exception_handler_should_override_existing_ones() { 21 | TestServer.reset() 22 | val defaultExceptionHandler = TestServer.appServer.exceptionHandlers.first() 23 | 24 | TestServer.appServer.exception({ response.setStatus(StatusCodes.NotImplemented) }) 25 | 26 | assertTrue(defaultExceptionHandler !== TestServer.appServer.exceptionHandlers.first()) 27 | } 28 | 29 | @spec fun adding_an_exception_handler_should_increase_exception_handlers_count() { 30 | TestServer.reset() 31 | val defaultExceptionHandlersCount = TestServer.appServer.exceptionHandlers.size 32 | 33 | TestServer.appServer.exception(Exception::class, { }) 34 | 35 | assertEquals(defaultExceptionHandlersCount + 1, TestServer.appServer.exceptionHandlers.size) 36 | } 37 | 38 | @spec fun repeatedly_adding_an_exception_handler_should_override_existing_ones() { 39 | TestServer.reset() 40 | val defaultExceptionHandlersCount = TestServer.appServer.exceptionHandlers.size 41 | val expectedExceptionHandler = exceptionHandler { response.setStatus(418, "I'm a teapot") } 42 | 43 | TestServer.appServer.exception(Exception::class, { }) 44 | TestServer.appServer.exception(Exception::class, expectedExceptionHandler) 45 | 46 | val actualExceptionHandler = TestServer.appServer.exceptionHandlers.filter { it.exceptionClass == Exception::class.java.name }.last() 47 | assertEquals(defaultExceptionHandlersCount + 1, TestServer.appServer.exceptionHandlers.size) 48 | assertTrue(expectedExceptionHandler === actualExceptionHandler.handler) 49 | } 50 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/ProtocolNegotiator.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import io.netty.channel.ChannelHandlerContext 4 | import io.netty.handler.codec.http.HttpObjectAggregator 5 | import io.netty.handler.codec.http.HttpRequestDecoder 6 | import io.netty.handler.codec.http.HttpResponseEncoder 7 | import io.netty.handler.ssl.ApplicationProtocolNames 8 | import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler 9 | import org.slf4j.LoggerFactory 10 | import org.wasabifx.wasabi.app.AppServer 11 | import org.wasabifx.wasabi.app.configuration 12 | import org.wasabifx.wasabi.core.HttpPipelineInitializer 13 | import org.wasabifx.wasabi.protocol.http2.Http2HandlerBuilder 14 | 15 | /** 16 | * This handles setting up the pipeline for SSL connections. 17 | * @property appServer The preconfigured wasabi application server instance. 18 | */ 19 | class ProtocolNegotiator(val appServer: AppServer) : ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_1_1) { 20 | 21 | private val logger = LoggerFactory.getLogger(ProtocolNegotiator::class.java) 22 | 23 | /** 24 | * This overrides the default Netty implementation, invokes the appropriate function 25 | * based on the incoming protocol type, we handle HTTP/1.1 and 1.0 within the same handler. 26 | */ 27 | override fun configurePipeline(ctx: ChannelHandlerContext?, protocol: String?) { 28 | when(protocol){ 29 | "h2"-> initHttp2Pipeline(ctx) 30 | "HTTP/1.1" -> initHttpPipeline(ctx) 31 | "HTTP/1.0" -> initHttpPipeline(ctx) 32 | else -> { 33 | throw IllegalStateException("unknown protocol: " + protocol) 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * Not much to this yet build and add! 40 | */ 41 | private fun initHttp2Pipeline(ctx: ChannelHandlerContext?) 42 | { 43 | logger.debug("Initialising HTTP/2 Pipeline") 44 | val pipeline = ctx!!.pipeline() 45 | pipeline.addLast("http2", Http2HandlerBuilder(appServer).build()) 46 | } 47 | 48 | /** 49 | * Here we setup our basic HTTP1 pipeline. 50 | */ 51 | private fun initHttpPipeline(ctx: ChannelHandlerContext?) 52 | { 53 | logger.debug("Initialising HTTP Pipeline") 54 | val pipeline = ctx!!.pipeline() 55 | pipeline.addLast("decoder", HttpRequestDecoder()) 56 | pipeline.addLast("encoder", HttpResponseEncoder()) 57 | pipeline.addLast("aggregator", HttpObjectAggregator(configuration!!.maxHttpContentLength)) 58 | pipeline.addLast("handler", HttpPipelineInitializer(appServer)) 59 | } 60 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/ConfigStorageSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.app.AppConfiguration 4 | import org.wasabifx.wasabi.configuration.ConfigurationStorage 5 | import java.io.File 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertFails 8 | import kotlin.test.assertTrue 9 | import org.junit.Test as spec 10 | 11 | class ConfigStorageSpecs { 12 | 13 | @spec fun loading_a_valid_configuration_file_should_correctly_load_all_values() { 14 | 15 | val configurationStorage = ConfigurationStorage() 16 | 17 | 18 | val configuration = configurationStorage.loadFromFile("testData${File.separatorChar}production.json") 19 | 20 | assertEquals(configuration.port, 5000) 21 | assertEquals(configuration.welcomeMessage, "Welcome to Wasabi!") 22 | assertEquals(configuration.enableLogging, true) 23 | 24 | } 25 | 26 | @spec fun loading_a_non_existent_configuration_file_should_throw_invalid_configuration_exception() { 27 | 28 | val configurationStorage = ConfigurationStorage() 29 | 30 | val exception = assertFails({ configurationStorage.loadFromFile("non_existing_file") }) 31 | 32 | 33 | assertEquals("Configuration file does not exist", exception.message) 34 | } 35 | 36 | @spec fun loading_an_invalid_configuration_with_invalid_property_should_throw_invalid_configuration_exception_with_name_of_invalid_property() { 37 | 38 | val configurationStorage = ConfigurationStorage() 39 | 40 | val exception = assertFails({ configurationStorage.loadFromFile("testData${File.separatorChar}production_bad_property.json")}) 41 | 42 | assertTrue(exception.message!!.contains("Invalid property in configuration file: Unrecognized field \"invalid_property\" (class org.wasabifx.wasabi.app.AppConfiguration), not marked as ignorable")) 43 | } 44 | 45 | @spec fun loading_an_invalid_configuration_with_invalid_json_should_throw_invalid_configuration_exception() { 46 | 47 | val configurationStorage = ConfigurationStorage() 48 | 49 | val exception = assertFails({ configurationStorage.loadFromFile("testData${File.separatorChar}production_bad_json.json")}) 50 | 51 | assertEquals("Invalid JSON in configuration file: [Source: testData${File.separatorChar}production_bad_json.json; line: 2, column: 6]", exception.message) 52 | } 53 | 54 | @spec fun saving_a_configuration_to_file_should_save_it_correctly() { 55 | 56 | val configurationStorage = ConfigurationStorage() 57 | 58 | 59 | val file = File.createTempFile("configuration", ".json") 60 | configurationStorage.saveToFile(AppConfiguration(), file.absolutePath) 61 | 62 | file.readText() 63 | 64 | assertTrue(file.exists()) 65 | 66 | } 67 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/ContentNegotiationParserInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import org.wasabifx.wasabi.app.AppServer 4 | import org.wasabifx.wasabi.protocol.http.Request 5 | import org.wasabifx.wasabi.protocol.http.Response 6 | import java.util.* 7 | 8 | 9 | class ContentNegotiationParserInterceptor(val mappings: HashMap = HashMap()): Interceptor { 10 | val ACCEPT_HEADER = 0 11 | val QUERY_PARAM = 1 12 | val EXTENSION = 2 13 | val orderQueue = LinkedList() 14 | var queryParameterName = "" 15 | 16 | override fun intercept(request: Request, response: Response): Boolean { 17 | var contentType = "" 18 | 19 | response.requestedContentTypes.clear() 20 | while (contentType == "" && orderQueue.size > 0) { 21 | var connegType = orderQueue.removeFirst() 22 | when (connegType) { 23 | ACCEPT_HEADER -> { 24 | for (mediaTypes in request.accept) { 25 | response.requestedContentTypes.add(mediaTypes.key) 26 | } 27 | } 28 | QUERY_PARAM -> { 29 | request.queryParams.get(queryParameterName)?.let { 30 | mappings.get(it)?.let { 31 | response.requestedContentTypes.add(it) 32 | } 33 | } 34 | } 35 | EXTENSION -> { 36 | request.document.dropWhile { it != '.' }.let { 37 | mappings.get(it)?.let { 38 | response.requestedContentTypes.add(it.toString()) 39 | } 40 | } 41 | } 42 | else -> { 43 | throw IllegalArgumentException("unknown conneg") 44 | } 45 | } 46 | 47 | } 48 | return true 49 | } 50 | 51 | fun onAcceptHeader(): ContentNegotiationParserInterceptor { 52 | orderQueue.add(ACCEPT_HEADER) 53 | return this 54 | } 55 | 56 | fun onQueryParameter(queryParameterName: String = "format"): ContentNegotiationParserInterceptor { 57 | this.queryParameterName = queryParameterName 58 | orderQueue.add(QUERY_PARAM) 59 | return this 60 | } 61 | 62 | fun onExtension(): ContentNegotiationParserInterceptor { 63 | orderQueue.add(EXTENSION) 64 | return this 65 | } 66 | 67 | } 68 | 69 | fun AppServer.parseContentNegotiationHeaders(path: String = "*", mappings: HashMap = hashMapOf(Pair("json","application/json"), Pair("xml", "application/xml")), body: ContentNegotiationParserInterceptor.()->Unit) { 70 | val conneg = ContentNegotiationParserInterceptor(mappings) 71 | conneg.body() 72 | intercept(conneg, path, InterceptOn.PostExecution) 73 | } 74 | 75 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/SessionManagementInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import io.netty.handler.codec.http.cookie.ServerCookieDecoder 4 | import io.netty.handler.codec.http.cookie.ServerCookieEncoder 5 | import org.junit.Test as spec 6 | import kotlin.test.assertEquals 7 | import org.junit.Ignore 8 | import org.wasabifx.wasabi.interceptors.enableSessionSupport 9 | import kotlin.test.assertTrue 10 | 11 | class SessionManagementInterceptorSpecs: TestServerContext() { 12 | 13 | class CustomSession(val name: String) { 14 | 15 | } 16 | @spec fun should_have_same_session_between_multiple_requests() { 17 | 18 | TestServer.appServer.enableSessionSupport() 19 | TestServer.appServer.get("/test_session", { 20 | 21 | }) 22 | 23 | // Make sure session id stays consistent between requests. 24 | val response = get("http://localhost:${TestServer.definedPort}/test_session") 25 | var cookieString = "" 26 | response.headers.forEach { 27 | if (it.name == "Set-Cookie") { 28 | cookieString = it.value 29 | } 30 | } 31 | 32 | assertTrue(cookieString.length > 0) 33 | 34 | /* 35 | * cookieString value is this: _sessionID=057907cf-9a08-48ff-9fbe-43c8d5ebb0fb; Path=/; Domain=localhost 36 | * so at first we split by ; so we will get _sessionID=057907cf-9a08-48ff-9fbe-43c8d5ebb0fb 37 | * and then split by = 38 | */ 39 | val (cookieName,sessionId) = cookieString.split(";")[0].split("=") 40 | 41 | // Set session cookie as you would expect the client to do... 42 | val response2 = get("http://localhost:${TestServer.definedPort}/test_session", hashMapOf(), hashMapOf(cookieName to sessionId)) 43 | 44 | // Check it comes back and matches original. 45 | var cookieString2 = "" 46 | response2.headers.iterator().forEach { 47 | if (it.name == "Set-Cookie") { 48 | cookieString2 = it.value 49 | } 50 | } 51 | assertEquals(cookieString, cookieString2) 52 | } 53 | 54 | @spec fun cookie_should_have_root_path() { 55 | TestServer.appServer.enableSessionSupport() 56 | TestServer.appServer.get("/test-session", { 57 | response.send("Test", "text/plain") 58 | }) 59 | 60 | // Make sure session id stays consistent between requests. 61 | val response = get("http://localhost:${TestServer.definedPort}/test-session", hashMapOf()) 62 | 63 | assertTrue(response.headers.filter { it.name == "Set-Cookie" }.count() > 0) 64 | 65 | response.headers.forEach { 66 | if (it.name == "Set-Cookie") { 67 | val parts = it.value.split(";") 68 | assertTrue(parts.filter { it.trim() == "Path=/" }.count() > 0) 69 | } 70 | } 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/Tester.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.app.AppConfiguration 4 | import java.util.Date 5 | import org.wasabifx.wasabi.app.AppServer 6 | import org.wasabifx.wasabi.interceptors.* 7 | import org.wasabifx.wasabi.protocol.http.CORSEntry 8 | import org.wasabifx.wasabi.protocol.http.Request 9 | import org.wasabifx.wasabi.protocol.http.Response 10 | import org.wasabifx.wasabi.protocol.http.StatusCodes 11 | import org.wasabifx.wasabi.routing.routeHandler 12 | import org.wasabifx.wasabi.routing.with 13 | import java.util.Calendar 14 | 15 | data class Person(val id: Int, val name: String, val email: String, val profession: String, val dateJoined: Date, val level: Int) 16 | 17 | 18 | 19 | 20 | 21 | 22 | val people = arrayListOf( 23 | Person(1, "Hadi Hariri", "mail@somewhere.com", "Developer", setDate(2005, 12, 10), 3), 24 | Person(2, "Joe Smith", "joe@somewhere.com", "Marketing", setDate(2007, 11, 3), 2), 25 | Person(3, "Jenny Jackson", "jenny@gmail.com", "Non Sleeper", setDate(2011, 6, 3), 1)) 26 | 27 | 28 | 29 | fun main(args: Array) { 30 | 31 | 32 | 33 | 34 | val appServer = AppServer(AppConfiguration(port = 8080, enableLogging = false)).apply { 35 | intercept(object : Interceptor { 36 | override fun intercept(request: Request, response: Response): Boolean = true 37 | }, "/api/:param/things", InterceptOn.PreExecution) 38 | get("/api/:param1/things", { 39 | val paramValue = request.routeParams["param1"] 40 | if (paramValue == "abc123") { 41 | response.send("ok!") 42 | } else { 43 | println("request param got messed up. should be 'abc123' but was $paramValue") 44 | response.statusCode = 500 45 | response.send("sad") 46 | } 47 | }) 48 | 49 | } 50 | 51 | appServer.start() 52 | } 53 | 54 | 55 | 56 | 57 | fun setDate(year: Int, month: Int, day: Int): Date { 58 | val cal = Calendar.getInstance() 59 | cal.set(year, month - 1, day) 60 | return cal.time 61 | } 62 | data class Customer(val id: Int, val name: String) 63 | 64 | val getPersons = routeHandler { 65 | 66 | response.send(people) 67 | } 68 | 69 | val getPersonById = routeHandler { 70 | 71 | val person = people.firstOrNull { it.id == request.routeParams["id"]?.toInt() } 72 | if (person != null) { 73 | response.send(person) 74 | } else { 75 | response.setStatus(StatusCodes.NotFound) 76 | } 77 | } 78 | 79 | val createPerson = routeHandler { 80 | 81 | val person = Person(people.count()+1, 82 | request.bodyParams["name"].toString(), 83 | request.bodyParams["email"].toString(), 84 | request.bodyParams["profession"].toString(), 85 | Date(), 86 | 1) 87 | people.add(person) 88 | response.resourceId = person.id.toString() 89 | 90 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/BodyParametersSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.Request 5 | import okhttp3.RequestBody 6 | import org.apache.http.NameValuePair 7 | import org.apache.http.message.BasicNameValuePair 8 | import org.wasabifx.wasabi.routing.routeHandler 9 | import java.nio.charset.Charset 10 | import java.util.* 11 | import kotlin.test.assertEquals 12 | import org.junit.Test as spec 13 | 14 | /** 15 | * Created by swishy on 25/11/15. 16 | */ 17 | class BodyParametersSpecs : TestServerContext() { 18 | 19 | val createMember = routeHandler { 20 | var memberName = request.bodyParams["memberName"] 21 | assert(memberName == "bob") 22 | } 23 | 24 | @spec fun requests_should_have_isolated_body_params() { 25 | 26 | TestServer.appServer.post("/params", { 27 | response.send(request.bodyParams.size.toString()) 28 | }) 29 | 30 | val urlParameters = ArrayList() 31 | urlParameters.add(BasicNameValuePair("foo", "bar")) 32 | 33 | val urlParameters2 = ArrayList() 34 | urlParameters2.add(BasicNameValuePair("bar", "baz")) 35 | 36 | val response = post("http://localhost:${TestServer.definedPort}/params", hashMapOf(), urlParameters) 37 | 38 | assert(response.body?.contains("1") ?: false) 39 | 40 | val response2 = post("http://localhost:${TestServer.definedPort}/params", hashMapOf(), urlParameters2) 41 | 42 | assert(response2.body?.contains("1") ?: false) 43 | 44 | } 45 | 46 | @spec fun body_params_present_on_request() { 47 | 48 | TestServer.appServer.post("/body", createMember) 49 | 50 | var urlParameters3 = ArrayList() 51 | urlParameters3.add(BasicNameValuePair("memberName", "bob")) 52 | 53 | post("http://localhost:${TestServer.definedPort}/body", hashMapOf(), urlParameters3) 54 | } 55 | 56 | @spec fun body_is_populated_even_with_invalid_content() { 57 | 58 | val testValue = "FOO" 59 | 60 | val headers = hashMapOf( 61 | "User-Agent" to "test-client", 62 | "Cache-Control" to "max-age=0", 63 | "Accept" to "application/json", 64 | "Accept-Charset" to "utf-8" 65 | ) 66 | 67 | TestServer.appServer.post("/raw", { 68 | response.send(String(request.body, Charset.forName("UTF-8"))) 69 | }) 70 | 71 | val client = OkHttpClient() 72 | val body = RequestBody.create(null, "FOO") 73 | val request = Request.Builder() 74 | .url("http://localhost:${TestServer.definedPort}/raw") 75 | .header("Accept", "application/json") 76 | .header("Accept-Encoding", "gzip, deflate") 77 | .method("POST", body) 78 | .build() 79 | 80 | val response = client.newCall(request).execute() 81 | assertEquals(testValue, response.body().string()) 82 | } 83 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/app/AppConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.app 2 | 3 | import org.yaml.snakeyaml.Yaml 4 | import java.io.File 5 | import java.io.FileInputStream 6 | import java.util.* 7 | import kotlin.reflect.full.memberProperties 8 | 9 | var configuration : AppConfiguration? = null 10 | 11 | 12 | data class AppConfiguration( 13 | var port: Int = 3000, 14 | var hostname: String? = null, 15 | var welcomeMessage: String = "Server starting ${hostname?.let { "at $hostname:$port" } ?: "on port $port"}", 16 | var enableContentNegotiation: Boolean = true, 17 | var enableLogging: Boolean = true, 18 | var enableAutoOptions: Boolean = false, 19 | var enableCORSGlobally: Boolean = false, 20 | var sessionLifetime: Int = 600, 21 | var enableXML11: Boolean = false, 22 | var maxHttpContentLength: Int = 1048576, 23 | var sslEnabled: Boolean = false, 24 | var sslCertificatePath: String = "" 25 | ) 26 | { 27 | private val logger = org.slf4j.LoggerFactory.getLogger(AppConfiguration::class.java) 28 | var sections: Map = HashMap() 29 | 30 | init{ 31 | // Check we have a wasabi configuration, if not assume programmatic configuration. 32 | val configurationFile = File("wasabi.yaml") 33 | val exists = configurationFile.exists() 34 | if(exists) { 35 | val yaml = Yaml() 36 | try { 37 | @Suppress("UNCHECKED_CAST") 38 | val configuration = yaml.load(FileInputStream(configurationFile)) as MutableMap 39 | 40 | @Suppress("UNCHECKED_CAST") 41 | val wasabiConfiguration = configuration["wasabi"] as Map 42 | AppConfiguration::class.memberProperties.forEach { 43 | // Ignore the logger .... 44 | if (it.name != "logger") 45 | { 46 | try { 47 | javaClass.getDeclaredField(it.name).set(this, wasabiConfiguration[it.name]) 48 | } 49 | catch(exception: Exception) 50 | { 51 | logger!!.debug("${it.name} setting not found in config, using default.") 52 | } 53 | } 54 | } 55 | 56 | // Drop wasabi from read config, its now set on object directly. 57 | configuration.remove("wasabi") 58 | 59 | // Assign custom config as immutable Map. 60 | sections = configuration as Map 61 | 62 | 63 | } 64 | catch(exception: Exception) 65 | { 66 | logger!!.debug("Unable to load configuration from file: $exception, using defaults or constructor provided values.") 67 | } 68 | } 69 | // Populate our static var, currently one instance is only ever created 70 | // so get's things going. 71 | configuration = this 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/ContentNegotiatioParserInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.junit.Test as spec 4 | import java.util.ArrayList 5 | import kotlin.test.assertEquals 6 | import org.wasabifx.wasabi.interceptors.parseContentNegotiationHeaders 7 | 8 | class ContentNegotiatioParserInterceptorSpecs: TestServerContext() { 9 | 10 | @spec fun acceptHeader_should_parse_the_accept_header_into_requested_content_types_with_each_media_type_on_own_line() { 11 | 12 | TestServer.appServer.parseContentNegotiationHeaders { 13 | onAcceptHeader() 14 | } 15 | 16 | 17 | val headers = hashMapOf( 18 | "User-Agent" to "test-client", 19 | "Cache-Control" to "max-age=0", 20 | "Accept" to "text/plain;q=0.8,application/xml,application/xhtml+xml,text/html;q=0.9", 21 | "Accept-Encoding" to "gzip,deflate,sdch", 22 | "Accept-Language" to "en-US,en;q=0.8", 23 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*" 24 | 25 | ) 26 | 27 | var sanitizedRequestedContentTypes : ArrayList = arrayListOf() 28 | 29 | 30 | TestServer.appServer.get("/contentTypes", 31 | { 32 | 33 | 34 | sanitizedRequestedContentTypes = response.requestedContentTypes 35 | response.send("/") 36 | 37 | }) 38 | 39 | get("http://localhost:${TestServer.definedPort}/contentTypes", headers) 40 | 41 | assertEquals("application/xhtml+xml", sanitizedRequestedContentTypes.get(0)) 42 | assertEquals("application/xml", sanitizedRequestedContentTypes.get(1)) 43 | assertEquals("text/html", sanitizedRequestedContentTypes.get(2)) 44 | assertEquals("text/plain", sanitizedRequestedContentTypes.get(3)) 45 | 46 | 47 | 48 | 49 | } 50 | 51 | @spec fun format_should_parse_the_url_query_into_requested_content_type() { 52 | 53 | TestServer.appServer.parseContentNegotiationHeaders { 54 | onQueryParameter() 55 | onAcceptHeader() 56 | onExtension() 57 | } 58 | 59 | val headers = hashMapOf( 60 | "User-Agent" to "test-client", 61 | "Cache-Control" to "max-age=0", 62 | "Accept" to "application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5", 63 | "Accept-Encoding" to "gzip,deflate,sdch", 64 | "Accept-Language" to "en-US,en;q=0.8", 65 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*" 66 | 67 | ) 68 | 69 | var sanitizedRequestedContentTypes : ArrayList = arrayListOf() 70 | 71 | 72 | TestServer.appServer.get("/contentTypes", 73 | { 74 | 75 | 76 | sanitizedRequestedContentTypes = response.requestedContentTypes 77 | response.send("/") 78 | 79 | }) 80 | 81 | get("http://localhost:${TestServer.definedPort}/contentTypes?format=json", headers) 82 | 83 | assertEquals("application/json", sanitizedRequestedContentTypes.get(0)) 84 | 85 | 86 | 87 | 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/interceptors/CORSInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.interceptors 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import org.wasabifx.wasabi.app.AppServer 5 | import org.wasabifx.wasabi.protocol.http.CORSEntry 6 | import org.wasabifx.wasabi.protocol.http.Request 7 | import org.wasabifx.wasabi.protocol.http.Response 8 | import org.wasabifx.wasabi.protocol.http.StatusCodes 9 | import org.wasabifx.wasabi.routing.PatternAndVerbMatchingRouteLocator 10 | import org.wasabifx.wasabi.routing.Route 11 | import java.util.* 12 | 13 | class CORSInterceptor(val routes: Set, val settings: ArrayList): Interceptor { 14 | override fun intercept(request: Request, response: Response): Boolean { 15 | val routeLocator = PatternAndVerbMatchingRouteLocator(routes) 16 | 17 | for (setting in settings) { 18 | if (setting.path == "*" || request.path.matches(setting.path.toRegex())) { 19 | 20 | // This covers non options requests, browser expects the below on the 21 | // request subsequent to the options request on CORS transfers. 22 | if (response.statusCode == StatusCodes.OK.code) { 23 | response.addRawHeader("Access-Control-Allow-Origin", setting.path) 24 | } 25 | 26 | // This handles the initial OPTIONS request during the CORS transfer. 27 | if (request.method == HttpMethod.OPTIONS) { 28 | val availableMethods = routes 29 | .filter { routeLocator.compareRouteSegments(it, request.path) } 30 | .map { it.method } 31 | .toSet() 32 | 33 | val allowedMethods = if (setting.methods == CORSEntry.ALL_AVAILABLE_METHODS) { 34 | availableMethods 35 | } else { 36 | availableMethods.intersect(setting.methods) 37 | } 38 | 39 | response.addRawHeader("Access-Control-Allow-Methods", allowedMethods.map { it.name() }.joinToString(",")) 40 | 41 | response.addRawHeader("Access-Control-Allow-Origin", setting.origins) 42 | if (setting.headers != "") { 43 | response.addRawHeader("Access-Control-Allow-Headers", setting.headers) 44 | } 45 | if (setting.credentials != "") { 46 | response.addRawHeader("Access-Control-Allow-Credentials", setting.credentials) 47 | } 48 | if (setting.preflightMaxAge != "") { 49 | response.addRawHeader("Access-Control-Max-Age", setting.preflightMaxAge) 50 | } 51 | 52 | response.setStatus(StatusCodes.OK) 53 | } 54 | } 55 | } 56 | 57 | return true 58 | } 59 | } 60 | 61 | fun AppServer.enableCORSGlobally() { 62 | enableCORS(arrayListOf(CORSEntry())) 63 | } 64 | 65 | fun AppServer.enableCORS(settings: ArrayList) { 66 | intercept(CORSInterceptor(routes, settings), "*", InterceptOn.PostRequest) 67 | } 68 | 69 | fun AppServer.disableCORS() { 70 | this.interceptors.removeAll { it.interceptor is CORSInterceptor } 71 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/core/HttpPipelineInitializer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.core 2 | 3 | import io.netty.channel.ChannelHandlerContext 4 | import io.netty.channel.SimpleChannelInboundHandler 5 | import io.netty.handler.codec.http.FullHttpRequest 6 | import io.netty.handler.codec.http.HttpHeaders 7 | import io.netty.handler.codec.http.HttpMessage 8 | import io.netty.handler.stream.ChunkedWriteHandler 9 | import org.slf4j.LoggerFactory 10 | import org.wasabifx.wasabi.app.AppServer 11 | import org.wasabifx.wasabi.protocol.http.HttpRequestHandler 12 | import org.wasabifx.wasabi.protocol.http2.Http2HandlerBuilder 13 | import org.wasabifx.wasabi.protocol.websocket.WebSocketFrameHandler 14 | import org.wasabifx.wasabi.protocol.websocket.WebSocketProtocolHandler 15 | import org.wasabifx.wasabi.routing.PatternMatchingChannelLocator 16 | 17 | class HttpPipelineInitializer(val appServer: AppServer) : SimpleChannelInboundHandler() { 18 | 19 | private val logger = LoggerFactory.getLogger(HttpPipelineInitializer::class.java) 20 | 21 | override fun channelRead0(ctx: ChannelHandlerContext?, msg: HttpMessage?) { 22 | // If we get here no connection upgrade was requested 23 | logger.debug("" + msg!!.protocolVersion() + " connection") 24 | 25 | // Increment the retain count due to our pipeline setup, if we don't it gets released during 26 | // WebSocket handshake or post pipeline flush. 27 | val fullMessage = msg as FullHttpRequest 28 | fullMessage.retain() 29 | 30 | when (msg.protocolVersion().text()) { 31 | "HTTP/2.0" -> initHttp2Pipeline(ctx, msg) 32 | "HTTP/1.1", "HTTP/1.0" -> initHttpPipeline(ctx, msg) 33 | else -> { 34 | throw IllegalStateException("unknown protocol: " + msg.protocolVersion().text()) 35 | } 36 | } 37 | } 38 | 39 | private fun initHttp2Pipeline(ctx: ChannelHandlerContext?, msg: HttpMessage?) { 40 | logger.debug("Initialising HTTP/2 Pipeline") 41 | val pipeline = ctx!!.pipeline() 42 | pipeline.addLast("http2", Http2HandlerBuilder(appServer).build()) 43 | ctx.executor() 44 | } 45 | 46 | private fun initHttpPipeline(ctx: ChannelHandlerContext?, msg: HttpMessage?) { 47 | logger.debug("Initialising HTTP Pipeline") 48 | if (msg!!.headers().get(HttpHeaders.Names.UPGRADE) == "websocket") { 49 | applyWebSocketPipeline(ctx, msg) 50 | } else { 51 | applyHttp1Pipeline(ctx, msg) 52 | } 53 | } 54 | 55 | private fun applyWebSocketPipeline(ctx: ChannelHandlerContext?, msg: HttpMessage?) { 56 | val fullMessage = msg as FullHttpRequest 57 | // TODO handle the channel not found exception gracefully... 58 | val path = fullMessage.uri 59 | val channel = PatternMatchingChannelLocator(appServer.channels).findChannelHandler(path) 60 | val pipeline = ctx!!.pipeline() 61 | val context = pipeline.context(this) 62 | pipeline.addLast(WebSocketProtocolHandler(path, null, true)) 63 | pipeline.addLast(WebSocketFrameHandler(channel.handler)) 64 | context.fireChannelRead(msg) 65 | } 66 | 67 | private fun applyHttp1Pipeline(ctx: ChannelHandlerContext?, msg: HttpMessage?) { 68 | val pipeline = ctx!!.pipeline() 69 | val context = pipeline.context(this) 70 | pipeline.addAfter(context.name(), "chunkedWriter", ChunkedWriteHandler()) 71 | pipeline.addAfter("chunkedWriter", "http1", HttpRequestHandler(appServer)) 72 | context.fireChannelRead(msg) 73 | } 74 | } -------------------------------------------------------------------------------- /wasabi-core/tools/build.gradle: -------------------------------------------------------------------------------- 1 | public static String getDomain(String s) { 2 | String[] f = s.split('\\.') 3 | return f[1].capitalize() 4 | } 5 | 6 | defaultTasks 'help' 7 | 8 | tasks.addRule("Pattern: =: Passes arguments to the scripts") { String taskName -> 9 | def match = taskName =~ /(.*?)=(.*?$)/ 10 | if(match){ 11 | task(taskName) << { 12 | } 13 | } 14 | } 15 | 16 | 17 | task help << { 18 | println ("""\ 19 | Welcome to Wasabi 20 | 21 | To get started run: gradle -q server -Pdir={directory} -Ppackage={package} 22 | 23 | e.g.: gradle -q server -Pdir=/home/username/project/myAppfolder -Ppackage=org.domain 24 | 25 | """) 26 | 27 | 28 | 29 | } 30 | 31 | task setup << { 32 | if(!project.hasProperty('dir')||!project.hasProperty('package')){ 33 | throw new GradleException('You need to specify a dir and package parameters') 34 | } else { 35 | def directory = new File(project.dir) 36 | println('Here we go...\n') 37 | directory.mkdirs() 38 | println('Creating directory ' + project.dir + '...') 39 | def sourcedir = new File(project.dir + '/src/main/kotlin') 40 | sourcedir.mkdirs() 41 | println('Creating src directory ...') 42 | def testdir = new File(project.dir + '/src/test/kotlin') 43 | testdir.mkdirs() 44 | println('Creating test directory ...') 45 | 46 | } 47 | } 48 | 49 | task createGradle(dependsOn: setup) << { 50 | println('Creating build.gradle file ...') 51 | def gradleFile = new File(project.dir+'/build.gradle') 52 | 53 | 54 | def mainClass = project.package + '.' + 'ServerKt' 55 | gradleFile.append("""\ 56 | buildscript { 57 | project.ext.kotlin_version = '0.1-SNAPSHOT' 58 | repositories { 59 | mavenCentral() 60 | maven { 61 | url "https://oss.sonatype.org/content/repositories/snapshots" 62 | } 63 | } 64 | dependencies { 65 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:\$kotlin_version" 66 | } 67 | } 68 | 69 | apply plugin: 'kotlin' 70 | apply plugin: 'application' 71 | 72 | mainClassName = '${mainClass}' 73 | 74 | repositories { 75 | mavenCentral() 76 | maven { 77 | url "https://oss.sonatype.org/content/repositories/snapshots" 78 | } 79 | maven { 80 | url 'https://dl.bintray.com/wasabifx/wasabifx' 81 | } 82 | } 83 | 84 | dependencies { 85 | compile "org.wasabifx:wasabi:0.3.30" 86 | compile "org.jetbrains.kotlin:kotlin-stdlib:\$kotlin_version" 87 | 88 | } 89 | 90 | sourceSets { 91 | src { 92 | main { 93 | kotlin 94 | } 95 | } 96 | test { 97 | main { 98 | kotlin 99 | } 100 | } 101 | main.java.srcDirs += 'src/main/kotlin' 102 | } 103 | 104 | task wrapper(type: Wrapper) { 105 | gradleVersion = '1.8' 106 | } 107 | """) 108 | } 109 | 110 | 111 | 112 | task server(dependsOn: createGradle) << { 113 | println('Creating server ...') 114 | def serverFile = new File(project.dir+'/src/main/kotlin/server.kt') 115 | serverFile.append("""\ 116 | package ${project.package} 117 | 118 | import org.wasabifx.wasabi.app.AppServer 119 | 120 | fun main(args: Array) { 121 | 122 | val server = AppServer() 123 | 124 | // insert routes here 125 | server.get("/", { response.send ("Hello Wasabi")}) 126 | 127 | server.start() 128 | 129 | } 130 | """) 131 | 132 | 133 | 134 | println('Done!') 135 | 136 | println(""" 137 | 138 | You can now import your Gradle project into your favorite IDE or use an editor. 139 | A default template has been created for you called server.kt. Define your routes there. 140 | 141 | Once you're ready to deploy, run: 142 | 143 | gradle distZip 144 | 145 | This will create a zip file with your application under build/distributions. 146 | Inside the zip there is a bin folder with two files named the same as your application. 147 | One is a shell script for Linux/OSX, the other for Windows. Running it will start your application. 148 | 149 | Have fun! 150 | 151 | 152 | 153 | """) 154 | 155 | } 156 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/StaticFileInterceptorSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.interceptors.serveStaticFilesFromFolder 4 | import org.junit.Test as spec 5 | import kotlin.test.assertEquals 6 | import java.io.File 7 | 8 | class StaticFileInterceptorSpecs: TestServerContext() { 9 | 10 | @spec fun requesting_an_existing_static_file_should_return_the_file() { 11 | 12 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 13 | 14 | val response = get("http://localhost:${TestServer.definedPort}/test.html", hashMapOf()) 15 | val response1 = get("http://localhost:${TestServer.definedPort}/error.html", hashMapOf()) 16 | 17 | 18 | assertEquals("This is an example static file", response.body) 19 | assertEquals("Standard Error File", response1.body) 20 | } 21 | 22 | @spec fun requesting_an_existing_static_file_should_return_correct_content_type() { 23 | 24 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 25 | 26 | val response = get("http://localhost:${TestServer.definedPort}/style.css", hashMapOf()) 27 | 28 | 29 | assertEquals("text/css", response.headers.first( { it.name == "Content-Type"}).value) 30 | } 31 | 32 | @spec fun requesting_an_non_existing_static_file_should_404() { 33 | 34 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 35 | 36 | val response = get("http://localhost:${TestServer.definedPort}/test1.html", hashMapOf()) 37 | 38 | 39 | assertEquals("Not Found", response.statusDescription) 40 | assertEquals(404, response.statusCode) 41 | } 42 | 43 | @spec fun requesting_an_existing_static_directory_should_go_to_next() { 44 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 45 | TestServer.loadDefaultRoutes() 46 | val response = get("http://localhost:${TestServer.definedPort}/", hashMapOf()) 47 | assertEquals("Root", response.body) 48 | } 49 | 50 | @spec fun requesting_an_existing_static_directory_should_serve_when_default_file_is_turn_on() { 51 | 52 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public", true, "test.html") 53 | 54 | val response = get("http://localhost:${TestServer.definedPort}/", hashMapOf()) 55 | 56 | assertEquals("This is an example static file", response.body) 57 | 58 | TestServer.reset() 59 | } 60 | 61 | @spec fun requesting_an_existing_static_file_with_additional_url_params_should_return_the_file() { 62 | 63 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 64 | 65 | val response = get("http://localhost:${TestServer.definedPort}/test.html?v=3", hashMapOf()) 66 | val response1 = get("http://localhost:${TestServer.definedPort}/error.html?test=test", hashMapOf()) 67 | 68 | 69 | assertEquals("This is an example static file", response.body) 70 | assertEquals("Standard Error File", response1.body) 71 | } 72 | 73 | @spec fun requesting_an_file_with_spaces_in_filename_should_work_correctly() { 74 | 75 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 76 | 77 | val response = get("http://localhost:${TestServer.definedPort}/file%20with%20spaces%20in%20filename.txt", hashMapOf()) 78 | 79 | assertEquals("lorem ipsum", response.body?.trim()) 80 | } 81 | 82 | @spec fun requesting_an_file_outside_of_static_folder_should_raise_internal_server_error() { 83 | 84 | TestServer.appServer.serveStaticFilesFromFolder("testData${File.separatorChar}public") 85 | 86 | val response = get("http://localhost:${TestServer.definedPort}/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd", hashMapOf()) 87 | 88 | assertEquals("Internal Server Error", response.body?.trim()) 89 | } 90 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/HttpServer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import io.netty.bootstrap.ServerBootstrap 4 | import io.netty.buffer.PooledByteBufAllocator 5 | import io.netty.channel.ChannelOption 6 | import io.netty.channel.nio.NioEventLoopGroup 7 | import io.netty.channel.socket.nio.NioServerSocketChannel 8 | import io.netty.handler.codec.http2.Http2SecurityUtil 9 | import io.netty.handler.ssl.* 10 | import io.netty.handler.ssl.util.SelfSignedCertificate 11 | import org.wasabifx.wasabi.app.AppServer 12 | import org.wasabifx.wasabi.app.configuration 13 | import org.wasabifx.wasabi.core.NettyPipelineInitializer 14 | import java.io.FileInputStream 15 | import java.security.KeyStore 16 | import java.security.cert.CertificateFactory 17 | import javax.net.ssl.TrustManagerFactory 18 | 19 | 20 | class HttpServer(private val appServer: AppServer) { 21 | 22 | val bootstrap: ServerBootstrap 23 | val primaryGroup : NioEventLoopGroup 24 | val workerGroup : NioEventLoopGroup 25 | val sslEnabled: Boolean = configuration!!.sslEnabled 26 | val sslCertificatePath = configuration!!.sslCertificatePath 27 | var sslContext: SslContext? = null 28 | 29 | init { 30 | 31 | // Setup SSL if we are using it. 32 | initialiseSsl() 33 | 34 | // Define worker groups 35 | primaryGroup = NioEventLoopGroup() 36 | workerGroup = NioEventLoopGroup() 37 | 38 | // Initialize bootstrap of server 39 | bootstrap = ServerBootstrap() 40 | 41 | bootstrap.group(primaryGroup, workerGroup) 42 | bootstrap.channel(NioServerSocketChannel::class.java) 43 | bootstrap.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) 44 | bootstrap.childHandler(NettyPipelineInitializer(appServer, sslContext)) 45 | 46 | } 47 | 48 | private fun initialiseSsl() { 49 | if (sslEnabled) { 50 | // Check for ALPN support in openssl, fallback to JDK implementation if not. 51 | val provider = if (OpenSsl.isAlpnSupported()) SslProvider.OPENSSL else SslProvider.JDK 52 | 53 | //var certificate : X509Certificate? 54 | 55 | // If the default empty string has been replaced in the wasabi configuration 56 | // attempt to read the file path into a stream. 57 | if (!sslCertificatePath.contentEquals("")) { 58 | 59 | val fileStream = FileInputStream(sslCertificatePath) 60 | 61 | val certificateFactory = CertificateFactory.getInstance("X.509") 62 | val caCertificate = certificateFactory.generateCertificate(fileStream) 63 | 64 | val trustManagerFactory = TrustManagerFactory 65 | .getInstance(TrustManagerFactory.getDefaultAlgorithm()) 66 | val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) 67 | 68 | // The keystore instance does't explicitly need to be derived from a 69 | // file. 70 | keyStore.load(null) 71 | keyStore.setCertificateEntry("caCert", caCertificate) 72 | 73 | trustManagerFactory.init(keyStore) 74 | 75 | // val sslContext = SSLContext.getInstance("TLS"); 76 | // sslContext.init(null, trustManagerFactory.trustManagers, null); 77 | } 78 | // TODO Need to load real certificate....s 79 | 80 | 81 | val certificate = SelfSignedCertificate() 82 | this.sslContext = SslContextBuilder.forServer(certificate.certificate(), certificate.privateKey()) 83 | .sslProvider(provider).ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) 84 | .applicationProtocolConfig(ApplicationProtocolConfig(ApplicationProtocolConfig.Protocol.ALPN, ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT 85 | , ApplicationProtocolNames.HTTP_2, ApplicationProtocolNames.HTTP_1_1)).build() 86 | 87 | } 88 | } 89 | 90 | fun start(wait: Boolean = true) { 91 | val configuration = appServer.configuration 92 | 93 | val channelFuture = configuration.hostname?.let { 94 | bootstrap.bind(it, configuration.port) 95 | } ?: bootstrap.bind(configuration.port) 96 | 97 | val channel = channelFuture?.sync()?.channel() 98 | 99 | if (wait) { 100 | channel?.closeFuture()?.sync() 101 | } 102 | } 103 | 104 | fun stop() { 105 | 106 | // Shutdown all event loops 107 | primaryGroup.shutdownGracefully() 108 | workerGroup.shutdownGracefully() 109 | 110 | // Wait till all threads are terminated 111 | primaryGroup.terminationFuture().sync() 112 | workerGroup.terminationFuture().sync() 113 | 114 | } 115 | 116 | 117 | } 118 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/Helpers.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.apache.http.Header 4 | import org.apache.http.NameValuePair 5 | import org.apache.http.client.config.RequestConfig 6 | import org.apache.http.client.entity.UrlEncodedFormEntity 7 | import org.apache.http.client.methods.* 8 | import org.apache.http.impl.client.BasicCookieStore 9 | import org.apache.http.impl.client.HttpClientBuilder 10 | import org.apache.http.impl.cookie.BasicClientCookie 11 | import org.apache.http.message.BasicNameValuePair 12 | import org.apache.http.util.EntityUtils 13 | import org.junit.After 14 | import org.junit.Before 15 | import org.wasabifx.wasabi.app.AppConfiguration 16 | import org.wasabifx.wasabi.app.AppServer 17 | import org.wasabifx.wasabi.routing.Route 18 | import java.net.BindException 19 | import java.util.* 20 | 21 | open class TestServerContext { 22 | @Before fun initTest(): Unit { 23 | TestServer.start() 24 | } 25 | @After fun postTest(): Unit { 26 | TestServer.reset() 27 | } 28 | } 29 | 30 | // TODO: Clean up response access for tests (switch to EasyHttp.jvm) 31 | 32 | object TestServer { 33 | 34 | val definedPort: Int = Random().nextInt(30000) + 5000 35 | var appServer: AppServer = AppServer(AppConfiguration( 36 | port = definedPort, 37 | hostname = "127.0.0.1" 38 | )) 39 | 40 | fun start() { 41 | try { 42 | appServer.start(false) 43 | } catch (e: BindException) { 44 | 45 | } 46 | } 47 | 48 | fun stop() { 49 | appServer.stop() 50 | } 51 | 52 | fun loadDefaultRoutes() { 53 | appServer.get("/", { response.send("Root")}) 54 | appServer.get("/first", { response.send("First")}) 55 | } 56 | 57 | val routes: Set 58 | get() = appServer.routes 59 | 60 | fun reset() { 61 | appServer.routes.clear() 62 | appServer.interceptors.clear() 63 | appServer.channels.clear() 64 | appServer.exceptionHandlers.clear() 65 | 66 | appServer.init() 67 | } 68 | } 69 | 70 | private fun makeRequest(headers: HashMap, request: HttpRequestBase, cookies: HashMap = hashMapOf()): HttpClientResponse { 71 | 72 | val cookieStore = BasicCookieStore() 73 | 74 | for ((key, value) in cookies) { 75 | val custom_cookie = BasicClientCookie(key, value) 76 | custom_cookie.path = request.uri?.path 77 | custom_cookie.domain = "localhost" 78 | cookieStore.addCookie(custom_cookie) 79 | } 80 | 81 | val httpClient = HttpClientBuilder.create().setDefaultCookieStore(cookieStore).build() 82 | for ((key, value) in headers) { 83 | request.setHeader(key, value) 84 | } 85 | request.setHeader("Connection", "Close") 86 | 87 | 88 | val response = httpClient.execute(request)!! 89 | 90 | val body = EntityUtils.toString(response.entity)!! 91 | val responseHeaders = response.allHeaders!! 92 | 93 | return HttpClientResponse(responseHeaders, body, 94 | response.statusLine?.statusCode!!, 95 | response.statusLine?.reasonPhrase ?: "") 96 | 97 | 98 | } 99 | 100 | fun delete(url: String, headers: HashMap): HttpClientResponse { 101 | return makeRequest(headers, HttpDelete(url)) 102 | } 103 | 104 | fun get(url: String, headers: HashMap = hashMapOf(), cookies: HashMap = hashMapOf()): HttpClientResponse { 105 | 106 | val requestConfig = RequestConfig.custom() 107 | .setRedirectsEnabled(false) 108 | .build() 109 | 110 | val get = HttpGet(url) 111 | get.config = requestConfig 112 | 113 | return makeRequest(headers, get, cookies) 114 | } 115 | 116 | fun post(url: String, headers: HashMap = hashMapOf(), postParams: List): HttpClientResponse { 117 | 118 | val requestConfig = RequestConfig.custom() 119 | .setRedirectsEnabled(false) 120 | .build() 121 | 122 | val post = HttpPost(url) 123 | post.config = requestConfig 124 | post.entity = UrlEncodedFormEntity(postParams) 125 | return makeRequest(headers, post) 126 | } 127 | 128 | fun options(url: String, headers: HashMap = hashMapOf()): HttpClientResponse { 129 | return makeRequest(headers, HttpOptions(url)) 130 | } 131 | 132 | 133 | fun postForm(url: String, headers: HashMap, fields: ArrayList, chunked: Boolean = false): HttpClientResponse { 134 | val httpPost = HttpPost(url) 135 | val entity = UrlEncodedFormEntity(fields, "UTF-8") 136 | entity.isChunked = chunked 137 | httpPost.setHeader("Content-Type", "application/x-www-form-urlencoded") 138 | httpPost.entity = entity 139 | return makeRequest(headers, httpPost) 140 | } 141 | 142 | data class HttpClientResponse(val headers: Array
, val body: String?, val statusCode: Int, val statusDescription: String) 143 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/TestClient.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.apache.http.client.entity.UrlEncodedFormEntity 5 | import org.apache.http.client.methods.* 6 | import org.apache.http.entity.StringEntity 7 | import org.apache.http.impl.client.BasicCookieStore 8 | import org.apache.http.impl.client.HttpClientBuilder 9 | import org.apache.http.impl.cookie.BasicClientCookie 10 | import org.apache.http.message.BasicNameValuePair 11 | import org.apache.http.util.EntityUtils 12 | import org.wasabifx.wasabi.app.AppServer 13 | import java.util.* 14 | 15 | class TestClient(val appServer: AppServer) { 16 | 17 | companion object Methods { 18 | val GET = "GET" 19 | val POST = "POST" 20 | val PUT = "PUT" 21 | val DELETE = "DELETE" 22 | val PATCH = "PATCH" 23 | val HEAD = "HEAD" 24 | val OPTIONS = "OPTIONS" 25 | val TRACE = "TRACE" 26 | } 27 | 28 | fun sendSimpleRequest(url: String, method: String, headers: HashMap = hashMapOf()): HttpClientResponse { 29 | val httpRequest = createHttpRequestInstance(url, method) 30 | return executeRequest(headers, httpRequest) 31 | } 32 | 33 | fun sendForm(url: String, method: String, fields: ArrayList, headers: HashMap = hashMapOf(), chunked: Boolean = false): HttpClientResponse { 34 | val httpRequest = createHttpRequestInstance(url, method) 35 | 36 | if (httpRequest is HttpEntityEnclosingRequestBase) { 37 | val entity = UrlEncodedFormEntity(fields, "UTF-8") 38 | entity.isChunked = chunked 39 | httpRequest.setHeader("Content-Type", "application/x-www-form-urlencoded") 40 | httpRequest.entity = entity 41 | } else { 42 | throw Exception("Cannot send form with this HTTP method ($method).") 43 | } 44 | 45 | return executeRequest(headers, httpRequest) 46 | } 47 | 48 | fun sendForm(url: String, method: String, fields: HashMap, headers: HashMap = hashMapOf(), chunked: Boolean = false): HttpClientResponse { 49 | 50 | val formFields = ArrayList() 51 | fields.forEach { 52 | formFields.add(BasicNameValuePair(it.key, it.value)) 53 | } 54 | 55 | return sendForm(url, method, formFields, headers, chunked) 56 | } 57 | 58 | fun sendJson(url: String, method: String, json: String, headers: HashMap = hashMapOf()): HttpClientResponse { 59 | val httpRequest = createHttpRequestInstance(url, method) 60 | 61 | if (httpRequest is HttpEntityEnclosingRequestBase) { 62 | httpRequest.setHeader("Content-Type", "application/json") 63 | httpRequest.entity = StringEntity(json) 64 | } else { 65 | throw Exception("Cannot send JSON with this HTTP method ($method).") 66 | } 67 | 68 | return executeRequest(headers, httpRequest) 69 | } 70 | 71 | fun sendJson(url: String, method: String, json: Any, headers: HashMap = hashMapOf()): HttpClientResponse { 72 | val objectMapper = ObjectMapper() 73 | val jsonString = objectMapper.writeValueAsString(json)!! 74 | 75 | return sendJson(url, method, jsonString, headers) 76 | } 77 | 78 | private fun createHttpRequestInstance(url: String, method: String): HttpRequestBase { 79 | val fullUrl = "http://localhost:" + appServer.configuration.port.toString() + url 80 | 81 | return when (method) { 82 | TestClient.GET -> { HttpGet(fullUrl) } 83 | TestClient.POST -> { HttpPost(fullUrl) } 84 | TestClient.PUT -> { HttpPut(fullUrl) } 85 | TestClient.DELETE -> { HttpDelete(fullUrl) } 86 | TestClient.PATCH -> { HttpPatch(fullUrl) } 87 | TestClient.HEAD -> { HttpHead(fullUrl) } 88 | TestClient.OPTIONS -> { HttpOptions(fullUrl) } 89 | TestClient.TRACE -> { HttpTrace(fullUrl) } // is not supported by wasabi server 90 | else -> { throw Exception("Invalid method") } 91 | } 92 | } 93 | 94 | private fun executeRequest(headers: HashMap, request: HttpRequestBase, cookies: HashMap = hashMapOf()): HttpClientResponse { 95 | 96 | val cookieStore = BasicCookieStore() 97 | 98 | for ((key, value) in cookies) { 99 | val custom_cookie = BasicClientCookie(key, value) 100 | custom_cookie.path = request.uri?.path 101 | custom_cookie.domain = "localhost" 102 | cookieStore.addCookie(custom_cookie) 103 | } 104 | 105 | val httpClient = HttpClientBuilder.create().setDefaultCookieStore(cookieStore).build() 106 | for ((key, value) in headers) { 107 | request.setHeader(key, value) 108 | } 109 | request.setHeader("Connection", "Close") 110 | 111 | val response = httpClient.execute(request)!! 112 | val body = if (response.entity != null) { EntityUtils.toString(response.entity) } else { null } 113 | val responseHeaders = response.allHeaders!! 114 | 115 | return HttpClientResponse(responseHeaders, body, 116 | response.statusLine?.statusCode!!, 117 | response.statusLine?.reasonPhrase ?: "") 118 | } 119 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/RoutesSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import org.wasabifx.wasabi.routing.PatternAndVerbMatchingRouteLocator 5 | import kotlin.test.assertEquals 6 | import kotlin.test.assertFails 7 | import kotlin.test.assertNotNull 8 | import org.junit.Test as spec 9 | 10 | 11 | class RoutesSpecs { 12 | 13 | @spec fun adding_a_route_to_routing_table_should_store_it() { 14 | 15 | TestServer.reset() 16 | TestServer.appServer.get("/", { }) 17 | 18 | assertEquals(1, TestServer.appServer.routes.size) 19 | } 20 | 21 | @spec fun finding_a_route_in_the_routing_table_should_return_route() { 22 | 23 | TestServer.reset() 24 | TestServer.appServer.get("/:id", { }) 25 | 26 | val routeLocator = PatternAndVerbMatchingRouteLocator(TestServer.routes) 27 | 28 | assertNotNull(routeLocator.findRouteHandlers("/1", HttpMethod.GET)) 29 | } 30 | 31 | @spec fun finding_a_route_in_the_routing_table_by_matching_method_and_path_should_return_route() { 32 | 33 | TestServer.reset() 34 | TestServer.appServer.get("/", { response.send("")}) 35 | TestServer.appServer.post("/second", { response.send("second")}) 36 | TestServer.appServer.post("/third", { response.send("third")}) 37 | 38 | val routeLocator = PatternAndVerbMatchingRouteLocator(TestServer.routes) 39 | 40 | val route1 = routeLocator.findRouteHandlers("/", HttpMethod.GET) 41 | val route2 = routeLocator.findRouteHandlers("/third", HttpMethod.POST) 42 | 43 | assertNotNull(route1) 44 | assertNotNull(route2) 45 | } 46 | 47 | @spec fun finding_a_route_in_the_routing_table_with_parameters_and_matching_method_should_return_route() { 48 | 49 | TestServer.reset() 50 | TestServer.appServer.post("/third", { response.send("third")}) 51 | TestServer.appServer.get("/first/:parent/:child/ending", { response.send("")}) 52 | 53 | val routeLocator = PatternAndVerbMatchingRouteLocator(TestServer.routes) 54 | 55 | val route1 = routeLocator.findRouteHandlers("/first/forest/trees/ENDING", HttpMethod.GET) 56 | 57 | assertNotNull(route1) 58 | 59 | } 60 | 61 | @spec fun finding_a_similar_route_in_the_routing_table_with_parameters_and_matching_method_should_return_route() { 62 | 63 | TestServer.reset() 64 | TestServer.appServer.get("/page/first", { response.send("first page")}) 65 | TestServer.appServer.get("/page/:id", { response.send("first id")}) 66 | 67 | val routeLocator = PatternAndVerbMatchingRouteLocator(TestServer.routes) 68 | 69 | val route1 = routeLocator.findRouteHandlers("/page/first", HttpMethod.GET) 70 | val route2 = routeLocator.findRouteHandlers("/page/smt", HttpMethod.GET) 71 | 72 | assertNotNull(route1) 73 | assertEquals("/page/first", route1.path) 74 | assertNotNull(route2) 75 | assertEquals("/page/:id", route2.path) 76 | } 77 | 78 | @spec fun finding_a_complex_route_in_the_routing_table_with_parameters_and_matching_method_should_return_route() { 79 | 80 | TestServer.reset() 81 | TestServer.appServer.get("/page/:id/view", { response.send("first page")}) 82 | TestServer.appServer.get("/page/:id/:format", { response.send("first id")}) 83 | 84 | val routeLocator = PatternAndVerbMatchingRouteLocator(TestServer.routes) 85 | 86 | val route1 = routeLocator.findRouteHandlers("/page/one/view", HttpMethod.GET) 87 | val route2 = routeLocator.findRouteHandlers("/page/one/json", HttpMethod.GET) 88 | 89 | assertNotNull(route1) 90 | assertEquals("/page/:id/view", route1.path) 91 | assertNotNull(route2) 92 | assertEquals("/page/:id/:format", route2.path) 93 | } 94 | 95 | @spec fun finding_a_route_in_the_routing_table_when_path_found_but_not_method_throw_exception_method_not_permitted() { 96 | 97 | TestServer.reset() 98 | TestServer.appServer.get( "/", { }) 99 | TestServer.appServer.post( "/second", { }) 100 | TestServer.appServer.post( "/third", { }) 101 | 102 | val routeLocator = PatternAndVerbMatchingRouteLocator(TestServer.routes) 103 | 104 | val exception = assertFails({routeLocator.findRouteHandlers("/second", HttpMethod.GET)}) 105 | 106 | assertEquals("Invalid method exception", exception.message) 107 | 108 | } 109 | 110 | @spec fun adding_a_second_route_in_the_routing_table_with_matching_path_and_method_should_throw_exception_indicating_route_exists() { 111 | 112 | TestServer.reset() 113 | TestServer.appServer.get( "/", {}) 114 | TestServer.appServer.get( "/a", {}) 115 | val exception = assertFails { TestServer.appServer.get( "/", {}) } 116 | 117 | assertEquals("Path / with method GET already exists", exception.message) 118 | } 119 | 120 | @spec fun adding_a_non_normal_route_in_the_routing_table_then_should_normalize_the_route() { 121 | 122 | TestServer.reset() 123 | TestServer.appServer.get("unreachable", {}) 124 | 125 | assertEquals("/unreachable", TestServer.appServer.routes.first().path) 126 | } 127 | 128 | @spec fun adding_a_second_route_in_the_routing_table_with_matching_path_param_and_method_should_throw_exception_indicating_route_exists() { 129 | 130 | TestServer.reset() 131 | TestServer.appServer.get( "/:param", {}) 132 | val exception = assertFails { TestServer.appServer.get( "/:id", {}) } 133 | 134 | assertEquals("Path /:param with method GET already exists", exception.message) 135 | } 136 | } 137 | 138 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 165 | if [[ "$(uname)" == "Darwin" ]] && [[ "$HOME" == "$PWD" ]]; then 166 | cd "$(dirname "$0")" 167 | fi 168 | 169 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 170 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/ContentType.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import java.util.ArrayList 4 | import kotlin.reflect.KProperty 5 | 6 | 7 | class ContentType(val contentType: String, val contentSubtype: String, val parameters: List> = listOf()) { 8 | 9 | override fun toString() = if (parameters.size == 0) "$contentType/$contentSubtype" else "$contentType/$contentSubtype; ${parameters.map { "${it.first}=${it.second}" }.joinToString("; ")}" 10 | 11 | fun withParameter(name: String, value: String): ContentType { 12 | val newParameters = ArrayList>(parameters) 13 | newParameters.add(name to value) 14 | return ContentType(contentType, contentSubtype, newParameters) 15 | } 16 | 17 | override fun equals(other: Any?) = when (other) { 18 | is ContentType -> contentType == other.contentType 19 | && contentSubtype == other.contentSubtype 20 | && parameters.size == other.parameters.size 21 | && parameters.indices.all { parameters.equals(other.parameters[it].first) } 22 | else -> false 23 | } 24 | 25 | companion object { 26 | fun parse(value: String): ContentType { 27 | val parts = value.split(";") 28 | val content = parts[0].split("/") 29 | if (content.size != 2) 30 | 31 | throw BadContentTypeFormat(value) 32 | val parameters = parts.drop(1).map { 33 | val pair = it.trim().split("=") 34 | if (pair.size != 2) 35 | throw BadContentTypeFormat(value) 36 | pair[0].trim() to pair[1].trim() 37 | } 38 | return ContentType(content[0].trim(), content[1].trim(), parameters) 39 | } 40 | 41 | val Any = ContentType("*", "*") 42 | object Application { 43 | val Any by AnyReflectionContentTypeProperty() 44 | val Atom by XmlReflectionContentTypeProperty() 45 | val Json by ReflectionContentTypeProperty() 46 | val JavaScript by ReflectionContentTypeProperty() 47 | val Octet_Stream by ReflectionContentTypeProperty() 48 | val Font_Woff by ReflectionContentTypeProperty() 49 | val Rss by XmlReflectionContentTypeProperty() 50 | val Xml by ReflectionContentTypeProperty() 51 | val Xml_Dtd by ReflectionContentTypeProperty() 52 | val Zip by ReflectionContentTypeProperty() 53 | val GZip by ReflectionContentTypeProperty() 54 | } 55 | object Audio { 56 | val Any by AnyReflectionContentTypeProperty() 57 | val MP4 by ReflectionContentTypeProperty() 58 | val MPEG by ReflectionContentTypeProperty() 59 | val OGG by ReflectionContentTypeProperty() 60 | } 61 | object Image { 62 | val Any by AnyReflectionContentTypeProperty() 63 | val GIF by ReflectionContentTypeProperty() 64 | val JPEG by ReflectionContentTypeProperty() 65 | val PNG by ReflectionContentTypeProperty() 66 | val SVG by XmlReflectionContentTypeProperty() 67 | } 68 | object Message { 69 | val Any by AnyReflectionContentTypeProperty() 70 | val Http by ReflectionContentTypeProperty() 71 | } 72 | object MultiPart { 73 | val Any by AnyReflectionContentTypeProperty() 74 | val Mixed by ReflectionContentTypeProperty() 75 | val Alternative by ReflectionContentTypeProperty() 76 | val Related by ReflectionContentTypeProperty() 77 | val Form_Data by ReflectionContentTypeProperty() 78 | val Signed by ReflectionContentTypeProperty() 79 | val Encrypted by ReflectionContentTypeProperty() 80 | } 81 | object Text { 82 | val Any by AnyReflectionContentTypeProperty() 83 | val Plain by ReflectionContentTypeProperty() 84 | val CSS by ReflectionContentTypeProperty() 85 | val Html by ReflectionContentTypeProperty() 86 | val JavaScript by ReflectionContentTypeProperty() 87 | val VCard by ReflectionContentTypeProperty() 88 | val Xml by ReflectionContentTypeProperty() 89 | } 90 | object Video { 91 | val Any by AnyReflectionContentTypeProperty() 92 | val MPEG by ReflectionContentTypeProperty() 93 | val MP4 by ReflectionContentTypeProperty() 94 | val OGG by ReflectionContentTypeProperty() 95 | val QuickTime by ReflectionContentTypeProperty() 96 | } 97 | } 98 | } 99 | 100 | 101 | class BadContentTypeFormat(value: String) : Exception("Bad Content-Type format: $value") 102 | 103 | class ReflectionContentTypeProperty(val parameters: List> = listOf()) { 104 | operator fun getValue(group: Any, property: KProperty<*>): ContentType { 105 | val contentType = group.javaClass.simpleName.toLowerCase() 106 | val contentSubtype = property.name.toLowerCase().replace("_", "-") 107 | return ContentType(contentType, contentSubtype, parameters) 108 | } 109 | } 110 | 111 | class XmlReflectionContentTypeProperty(val parameters: List> = listOf()) { 112 | operator fun getValue(group: Any, property: KProperty<*>): ContentType { 113 | val contentType = group.javaClass.simpleName.toLowerCase() 114 | val contentSubtype = property.name.toLowerCase().replace("_", "-") + "+xml" 115 | return ContentType(contentType, contentSubtype, parameters) 116 | } 117 | } 118 | 119 | class AnyReflectionContentTypeProperty(val parameters: List> = listOf()) { 120 | operator fun getValue(group: Any, property: KProperty<*>): ContentType { 121 | val contentType = group.javaClass.simpleName.toLowerCase() 122 | val contentSubtype = "*" 123 | return ContentType(contentType, contentSubtype, parameters) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/app/AppServer.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.app 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import org.slf4j.LoggerFactory 5 | import org.wasabifx.wasabi.deserializers.Deserializer 6 | import org.wasabifx.wasabi.deserializers.JsonDeserializer 7 | import org.wasabifx.wasabi.deserializers.MultiPartFormDataDeserializer 8 | import org.wasabifx.wasabi.interceptors.* 9 | import org.wasabifx.wasabi.protocol.http.HttpServer 10 | import org.wasabifx.wasabi.protocol.http.StatusCodes 11 | import org.wasabifx.wasabi.protocol.websocket.Channel 12 | import org.wasabifx.wasabi.protocol.websocket.ChannelHandler 13 | import org.wasabifx.wasabi.protocol.websocket.channelClients 14 | import org.wasabifx.wasabi.routing.* 15 | import org.wasabifx.wasabi.serializers.JsonSerializer 16 | import org.wasabifx.wasabi.serializers.Serializer 17 | import org.wasabifx.wasabi.serializers.TextPlainSerializer 18 | import org.wasabifx.wasabi.serializers.XmlSerializer 19 | import java.util.* 20 | import kotlin.reflect.KClass 21 | 22 | open class AppServer(val configuration: AppConfiguration = AppConfiguration()) { 23 | 24 | private val logger = LoggerFactory.getLogger(AppServer::class.java) 25 | private val httpServer: HttpServer 26 | private var running = false 27 | 28 | val routes: MutableSet = mutableSetOf() 29 | val channels: ArrayList = ArrayList() 30 | val exceptionHandlers: MutableSet = mutableSetOf() 31 | val interceptors : ArrayList = ArrayList() 32 | val serializers: ArrayList = arrayListOf(JsonSerializer(), XmlSerializer(), TextPlainSerializer()) 33 | val deserializers: ArrayList = arrayListOf(MultiPartFormDataDeserializer(), JsonDeserializer()) 34 | 35 | // TODO make configurable. 36 | val routeLocator = PatternAndVerbMatchingRouteLocator(routes) 37 | var exceptionLocator = ClassMatchingExceptionHandlerLocator(exceptionHandlers) 38 | 39 | init { 40 | httpServer = HttpServer(this) 41 | init() 42 | } 43 | 44 | private fun addRoute(method: HttpMethod, path: String, vararg handler: RouteHandler.() -> Unit) { 45 | val normalizedPath = normalizePath(path) 46 | val existingRoute = routes.findSimilar(method, normalizedPath) 47 | if (existingRoute != null) { 48 | throw RouteAlreadyExistsException(existingRoute) 49 | } 50 | routes.add(Route(normalizedPath, method, HashMap(), *handler)) 51 | } 52 | 53 | private fun normalizePath(path: String): String { 54 | return if (path.startsWith("/")) path else "/" + path 55 | } 56 | 57 | private fun addChannel(path: String, handler: ChannelHandler.() -> Unit) { 58 | val existingChannel = channels.filter{ it.path == path } 59 | if (existingChannel.count() >= 1) { 60 | throw ChannelAlreadyExistsException(existingChannel.firstOrNull()!!) 61 | } 62 | channels.add(Channel(path, handler)) 63 | channelClients.put(path, ArrayList()) 64 | } 65 | 66 | private fun addExceptionHandler(exceptionClass: String, handler: ExceptionHandler.() -> Unit) { 67 | val newRouteException = RouteException(exceptionClass, handler) 68 | exceptionHandlers.remove(newRouteException) 69 | exceptionHandlers.add(newRouteException) 70 | } 71 | 72 | fun init() { 73 | if (configuration.enableLogging) { 74 | intercept(LoggingInterceptor()) 75 | } 76 | if (configuration.enableContentNegotiation) { 77 | enableContentNegotiation() 78 | } 79 | if (configuration.enableAutoOptions) { 80 | enableAutoOptions() 81 | } 82 | if (configuration.enableCORSGlobally) { 83 | enableCORSGlobally() 84 | } 85 | exception { 86 | logger.error("Uncaught exception: ", exception) 87 | response.setStatus(StatusCodes.InternalServerError) 88 | } 89 | } 90 | 91 | /** 92 | * Returns true if the Server is running. 93 | */ 94 | val isRunning: Boolean 95 | get () 96 | { 97 | return running 98 | } 99 | 100 | /** 101 | * Starts the server 102 | * 103 | * @param wait 104 | */ 105 | fun start(wait: Boolean = true) { 106 | logger!!.info(configuration.welcomeMessage) 107 | 108 | running = true 109 | httpServer.start(wait) 110 | 111 | } 112 | 113 | /** 114 | * Stops the server 115 | * 116 | */ 117 | fun stop() { 118 | httpServer.stop() 119 | running = false 120 | logger!!.info("Server Stopped") 121 | } 122 | 123 | 124 | /** 125 | * 126 | */ 127 | fun get(path: String, vararg handlers: RouteHandler.() -> Unit) { 128 | addRoute(HttpMethod.GET, path, *handlers) 129 | } 130 | 131 | fun post(path: String, vararg handlers: RouteHandler.() -> Unit) { 132 | addRoute(HttpMethod.POST, path, *handlers) 133 | } 134 | 135 | fun put(path: String, vararg handlers: RouteHandler.() -> Unit) { 136 | addRoute(HttpMethod.PUT, path, *handlers) 137 | } 138 | 139 | fun head(path: String, vararg handlers: RouteHandler.() -> Unit) { 140 | addRoute(HttpMethod.HEAD, path, *handlers) 141 | } 142 | 143 | fun delete(path: String, vararg handlers: RouteHandler.() -> Unit) { 144 | addRoute(HttpMethod.DELETE, path, *handlers) 145 | } 146 | 147 | fun options(path: String, vararg handler: RouteHandler.() -> Unit) { 148 | addRoute(HttpMethod.OPTIONS, path, *handler) 149 | } 150 | 151 | fun patch(path: String, vararg handler: RouteHandler.() -> Unit) { 152 | // TODO: Check 153 | addRoute(HttpMethod.PATCH, path, *handler) 154 | } 155 | 156 | fun channel(path: String, handler: ChannelHandler.() -> Unit) { 157 | addChannel(path, handler) 158 | } 159 | 160 | fun exception(exception: KClass<*>, handler: ExceptionHandler.() -> Unit) { 161 | addExceptionHandler(exception.qualifiedName!!, handler) 162 | } 163 | 164 | fun exception(handler: ExceptionHandler.() -> Unit) { 165 | addExceptionHandler("", handler) 166 | } 167 | 168 | fun intercept(interceptor: Interceptor, path: String = "*", interceptOn: InterceptOn = InterceptOn.PreExecution) { 169 | interceptors.add(InterceptorEntry(interceptor, path, interceptOn)) 170 | } 171 | } 172 | 173 | 174 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/CorsSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import org.junit.Test 5 | import org.wasabifx.wasabi.interceptors.CORSInterceptor 6 | import org.wasabifx.wasabi.interceptors.disableCORS 7 | import org.wasabifx.wasabi.interceptors.enableCORS 8 | import org.wasabifx.wasabi.interceptors.enableCORSGlobally 9 | import org.wasabifx.wasabi.protocol.http.CORSEntry 10 | import kotlin.test.assertEquals 11 | 12 | fun getHeader(headerName: String, response: HttpClientResponse): String? { 13 | return response.headers 14 | .filter { it.name == headerName} 15 | .firstOrNull()?.value 16 | } 17 | 18 | fun getCORSAllowHeader(response: HttpClientResponse): String? { 19 | return getHeader("Access-Control-Allow-Methods", response) 20 | } 21 | 22 | class CorsSpecs : TestServerContext(){ 23 | 24 | @Test fun testing_cors_enabled () { 25 | 26 | //TestServer.appServer.enableAutoOptions() 27 | TestServer.appServer.enableCORS(arrayListOf(CORSEntry())) 28 | 29 | assertEquals(1, TestServer.appServer.interceptors.count { it.interceptor is CORSInterceptor }) 30 | 31 | TestServer.appServer.disableCORS() 32 | } 33 | 34 | @Test fun cors_should_only_work_on_declared_routes () { 35 | TestServer.appServer.get("/person", {}) 36 | TestServer.appServer.post("/person", {}) 37 | 38 | TestServer.appServer.post("/customer", {}) 39 | TestServer.appServer.enableCORS(arrayListOf(CORSEntry(path = "/person"))) 40 | 41 | val response = options("http://localhost:${TestServer.definedPort}/person") 42 | assertEquals("*", response.headers.filter { it.name == "Access-Control-Allow-Origin"}.first().value) 43 | assertEquals("Origin, X-Requested-With, Content-Type, Accept", response.headers.filter { it.name == "Access-Control-Allow-Headers"}.first().value) 44 | assertEquals("GET,POST", getCORSAllowHeader(response)) 45 | 46 | val response2 = options("http://localhost:${TestServer.definedPort}/customer") 47 | assertEquals(405, response2.statusCode) 48 | 49 | TestServer.appServer.disableCORS() 50 | } 51 | 52 | @Test fun cors_should_work_on_all_when_globally_enabled () { 53 | TestServer.appServer.get("/person", {}) 54 | TestServer.appServer.post("/person", {}) 55 | TestServer.appServer.put("/person", {}) 56 | 57 | TestServer.appServer.post("/customer", {}) 58 | TestServer.appServer.enableCORSGlobally() 59 | 60 | val response = options("http://localhost:${TestServer.definedPort}/person") 61 | assertEquals("*", response.headers.filter { it.name == "Access-Control-Allow-Origin"}.first().value) 62 | assertEquals("Origin, X-Requested-With, Content-Type, Accept", response.headers.filter { it.name == "Access-Control-Allow-Headers"}.first().value) 63 | assertEquals("GET,POST,PUT", getCORSAllowHeader(response)) 64 | 65 | val response2 = options("http://localhost:${TestServer.definedPort}/customer") 66 | assertEquals("*", response2.headers.filter { it.name == "Access-Control-Allow-Origin"}.first().value) 67 | assertEquals("Origin, X-Requested-With, Content-Type, Accept", response2.headers.filter { it.name == "Access-Control-Allow-Headers"}.first().value) 68 | assertEquals("POST", getCORSAllowHeader(response2)) 69 | 70 | TestServer.appServer.disableCORS() 71 | } 72 | 73 | @Test fun cors_should_fail_when_not_enabled () { 74 | TestServer.appServer.post("/customer", {}) 75 | 76 | val response = options("http://localhost:${TestServer.definedPort}/customer") 77 | 78 | assertEquals(405, response.statusCode) 79 | } 80 | 81 | @Test fun cors_should_return_correct_header_on_non_option_request () { 82 | TestServer.appServer.get("/person", {}) 83 | TestServer.appServer.post("/customer", {}) 84 | TestServer.appServer.enableCORS(arrayListOf(CORSEntry(path = "/person"))) 85 | 86 | val getResponse = get("http://localhost:${TestServer.definedPort}/person") 87 | assertEquals("/person", getResponse.headers.filter { it.name == "Access-Control-Allow-Origin"}.first().value) 88 | 89 | TestServer.appServer.disableCORS() 90 | } 91 | 92 | @Test fun cors_should_return_correct_header_on_globally_enabled_cors () { 93 | TestServer.appServer.get("/person", {}) 94 | TestServer.appServer.post("/person", {}) 95 | TestServer.appServer.patch("/person", {}) 96 | TestServer.appServer.head("/person", {}) 97 | TestServer.appServer.enableCORSGlobally() 98 | 99 | val response = options("http://localhost:${TestServer.definedPort}/person") 100 | assertEquals("GET,POST,PATCH,HEAD", getCORSAllowHeader(response)) 101 | 102 | TestServer.appServer.disableCORS() 103 | } 104 | 105 | @Test fun cors_should_return_correct_header_on_custom_cors_rules () { 106 | TestServer.appServer.get("/person", {}) 107 | TestServer.appServer.post("/person", {}) 108 | TestServer.appServer.patch("/person", {}) 109 | 110 | TestServer.appServer.get("/personalization", {}) 111 | 112 | TestServer.appServer.get("/account", {}) 113 | TestServer.appServer.post("/account", {}) 114 | TestServer.appServer.patch("/account", {}) 115 | 116 | TestServer.appServer.get("/thread", {}) 117 | TestServer.appServer.post("/thread", {}) 118 | TestServer.appServer.delete("/thread", {}) 119 | 120 | TestServer.appServer.enableCORS( 121 | arrayListOf( 122 | CORSEntry(path = "/person.*", methods = CORSEntry.ALL_AVAILABLE_METHODS), 123 | CORSEntry(path = "/account.*", methods = setOf(HttpMethod.GET, HttpMethod.POST)), 124 | CORSEntry(path = "/thread.*", methods = CORSEntry.NO_METHODS) 125 | ) 126 | ) 127 | 128 | val personResponse = options("http://localhost:${TestServer.definedPort}/person") 129 | assertEquals("GET,POST,PATCH", getCORSAllowHeader(personResponse)) 130 | 131 | val personalizationResponse = options("http://localhost:${TestServer.definedPort}/personalization") 132 | assertEquals("GET", getCORSAllowHeader(personalizationResponse)) 133 | 134 | val accountResponse = options("http://localhost:${TestServer.definedPort}/account") 135 | assertEquals("GET,POST", getCORSAllowHeader(accountResponse)) 136 | 137 | val threadResponse = options("http://localhost:${TestServer.definedPort}/thread") 138 | assertEquals(null, getCORSAllowHeader(threadResponse)) 139 | 140 | TestServer.appServer.disableCORS() 141 | } 142 | } -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/HeaderSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | 4 | import org.apache.http.message.BasicNameValuePair 5 | import org.wasabifx.wasabi.protocol.http.Cookie 6 | import java.util.* 7 | import kotlin.test.assertEquals 8 | import org.junit.Test as spec 9 | 10 | 11 | class HeaderSpecs : TestServerContext() { 12 | 13 | @spec fun request_with_get_should_contain_all_fields() { 14 | 15 | 16 | val headers = hashMapOf( 17 | "User-Agent" to "test-client", 18 | "Connection" to "close", 19 | "Cache-Control" to "max-age=0", 20 | "Accept" to "text/html,application/xhtml+xml;q=0.4,application/xml", 21 | "Accept-Encoding" to "gzip,deflate,sdch", 22 | "Accept-Language" to "en-US,en;q=0.8", 23 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*" 24 | 25 | ) 26 | 27 | var uri = "" 28 | var path = "" 29 | var port = 0 30 | var host = "" 31 | var userAgent = "" 32 | var connection = "" 33 | var cacheControl = "" 34 | var accept = sortedMapOf() 35 | var acceptEncoding = sortedMapOf() 36 | var acceptLanguage = sortedMapOf() 37 | var acceptCharset = sortedMapOf() 38 | var queryParams = HashMap() 39 | var routeParams = HashMap() 40 | 41 | 42 | 43 | TestServer.appServer.get("/customer/:id/:section", 44 | { 45 | 46 | 47 | uri = request.uri 48 | path = request.path 49 | host = request.host 50 | port = request.port 51 | userAgent = request.userAgent 52 | connection = request.connection 53 | cacheControl = request.cacheControl 54 | accept = request.accept 55 | acceptEncoding = request.acceptEncoding 56 | acceptLanguage = request.acceptLanguage 57 | acceptCharset = request.acceptCharset 58 | queryParams = request.queryParams 59 | routeParams = request.routeParams 60 | response.send("/") 61 | 62 | 63 | }) 64 | 65 | get("http://localhost:${TestServer.definedPort}/customer/10/valid?param1=value1¶m2=value2", headers) 66 | 67 | assertEquals("/customer/10/valid?param1=value1¶m2=value2", uri) 68 | assertEquals("/customer/10/valid", path) 69 | assertEquals("localhost", host) 70 | assertEquals(TestServer.definedPort, port) 71 | assertEquals("test-client", userAgent) 72 | assertEquals("Close", connection) 73 | assertEquals("max-age=0", cacheControl) 74 | assertEquals(3, accept.size) 75 | assertEquals(4, accept["application/xhtml+xml"]) 76 | assertEquals(3, acceptEncoding.size) 77 | assertEquals(2, acceptLanguage.size) 78 | assertEquals(3, acceptCharset.size) 79 | assertEquals(2, queryParams.size) 80 | assertEquals("value1", queryParams["param1"]) 81 | assertEquals("value2", queryParams["param2"]) 82 | assertEquals("10", routeParams["id"]) 83 | assertEquals("valid", routeParams["section"]) 84 | 85 | 86 | } 87 | 88 | @spec fun request_with_url_form_encoded_post_should_contain_post_fields_in_bodyParams() { 89 | 90 | val headers = hashMapOf( 91 | "User-Agent" to "test-client", 92 | "Cache-Control" to "max-age=0", 93 | "Accept" to "text/html,application/xhtml+xml,application/xml", 94 | "Accept-Encoding" to "gzip,deflate,sdch", 95 | "Accept-Language" to "en-US,en;q=0.8", 96 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*" 97 | 98 | ) 99 | 100 | var bodyParams = HashMap() 101 | 102 | 103 | 104 | 105 | TestServer.appServer.post("/customer", 106 | { 107 | 108 | System.out.println(request.bodyParams) 109 | 110 | bodyParams = request.bodyParams 111 | response.send("/") 112 | 113 | }) 114 | 115 | val fields = arrayListOf(BasicNameValuePair("name", "joe"), BasicNameValuePair("email", "joe@joe.com")) 116 | postForm("http://localhost:${TestServer.definedPort}/customer", headers, fields) 117 | 118 | assertEquals(2, bodyParams.size) 119 | assertEquals("joe", bodyParams["name"]) 120 | assertEquals("joe@joe.com", bodyParams["email"]) 121 | 122 | 123 | } 124 | 125 | 126 | @spec fun setting_a_cookie_when_making_a_request_should_set_the_cookie_value_in_the_request() { 127 | 128 | val headers = hashMapOf( 129 | "User-Agent" to "test-client", 130 | "Cache-Control" to "max-age=0", 131 | "Accept" to "text/html,application/xhtml+xml,application/xml", 132 | "Accept-Encoding" to "gzip,deflate,sdch", 133 | "Accept-Language" to "en-US,en;q=0.8", 134 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*" 135 | 136 | ) 137 | 138 | var cookies = HashMap() 139 | 140 | TestServer.appServer.get("/cookie", { 141 | 142 | cookies = request.cookies 143 | response.send("Nothing") 144 | 145 | }) 146 | get("http://localhost:${TestServer.definedPort}/cookie", headers, hashMapOf(Pair("someCookie", "someCookieValue"))) 147 | 148 | assertEquals("someCookieValue", cookies["someCookie"]?.value()) 149 | 150 | 151 | } 152 | 153 | 154 | @spec fun request_with_url_form_encoded_post_and_chunked_encoding_should_contain_post_fields_in_bodyParams() { 155 | 156 | val headers = hashMapOf( 157 | "User-Agent" to "test-client", 158 | "Cache-Control" to "max-age=0", 159 | "Accept" to "text/html,application/xhtml+xml,application/xml", 160 | "Accept-Encoding" to "gzip,deflate,sdch", 161 | "Accept-Language" to "en-US,en;q=0.8", 162 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*;q=0.3" 163 | 164 | ) 165 | 166 | var bodyParams = HashMap() 167 | 168 | TestServer.appServer.post("/customer", 169 | { 170 | bodyParams = request.bodyParams 171 | response.send("/") 172 | }) 173 | 174 | val fields = arrayListOf(BasicNameValuePair("name", "joe"), BasicNameValuePair("email", "joe@joe.com")) 175 | postForm("http://localhost:${TestServer.definedPort}/customer", headers, fields, true) 176 | 177 | assertEquals(2, bodyParams.size) 178 | assertEquals("joe", bodyParams["name"]) 179 | assertEquals("joe@joe.com", bodyParams["email"]) 180 | } 181 | } 182 | 183 | -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/Response.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import io.netty.handler.codec.http.cookie.ServerCookieEncoder 5 | import org.joda.time.DateTime 6 | import org.joda.time.DateTimeZone 7 | import org.joda.time.format.DateTimeFormat 8 | import java.io.File 9 | import java.util.* 10 | import javax.activation.MimetypesFileTypeMap 11 | 12 | 13 | class Response() { 14 | 15 | private val rawHeaders: HashMap = HashMap() 16 | 17 | var etag: String = "" 18 | var resourceId: String? = null 19 | var location: String = "" 20 | var contentType: String = ContentType.Companion.Text.Plain.toString() 21 | var contentLength: Long? = null 22 | var statusCode: Int = 200 23 | var statusDescription: String = "" 24 | var allow: String = "" 25 | var sendBuffer: Any? = null 26 | private set 27 | var overrideContentNegotiation: Boolean = false 28 | val cookies : HashMap = HashMap() 29 | var requestedContentTypes: ArrayList = arrayListOf() 30 | var negotiatedMediaType: String = "" 31 | var connection: String = "close" 32 | var cacheControl: String = "max-age=0" 33 | var lastModified: DateTime? = null 34 | 35 | 36 | 37 | fun redirect(url: String, redirectType: StatusCodes = StatusCodes.Found) { 38 | setStatus(redirectType) 39 | location = url 40 | } 41 | 42 | @Deprecated("Use sendFile() instead", ReplaceWith("sendFile(filename, contentType)")) 43 | fun setFileResponseHeaders(filename: String, contentType: String = "*/*") { 44 | this.sendFile(filename, contentType) 45 | } 46 | 47 | fun sendFile(filename: String, contentType: String = "*/*") { 48 | 49 | val file = File(filename) 50 | if (file.exists() && !file.isDirectory) { 51 | sendBuffer = file.readBytes() 52 | 53 | val fileContentType : String? 54 | when (contentType) { 55 | "*/*" -> when { 56 | file.extension.compareTo("css", ignoreCase = true) == 0 -> { 57 | fileContentType = "text/css" 58 | } 59 | file.extension.compareTo("js", ignoreCase = true) == 0 -> { 60 | fileContentType = "application/javascript" 61 | } 62 | else -> { 63 | val mimeTypesMap: MimetypesFileTypeMap? = MimetypesFileTypeMap() 64 | fileContentType = mimeTypesMap!!.getContentType(file) 65 | } 66 | } 67 | else -> { 68 | fileContentType = contentType 69 | } 70 | } 71 | this.negotiatedMediaType = fileContentType ?: "application/unknown" 72 | this.contentLength = file.length() 73 | this.lastModified = DateTime(file.lastModified()) 74 | 75 | } else { 76 | setStatus(StatusCodes.NotFound) 77 | } 78 | } 79 | 80 | 81 | fun send(obj: Any, contentType: String = "*/*") { 82 | sendBuffer = obj 83 | if (contentType != "*/*") { 84 | negotiatedMediaType = contentType 85 | } 86 | } 87 | 88 | 89 | fun negotiate(vararg negotiations: Pair Unit>) { 90 | for ((mediaType, func) in negotiations) { 91 | if (requestedContentTypes.any { it.compareTo(mediaType, ignoreCase = true) == 0}) { 92 | func() 93 | negotiatedMediaType = mediaType 94 | return 95 | } 96 | } 97 | setStatus(StatusCodes.UnsupportedMediaType) 98 | } 99 | 100 | fun setStatus(statusCode: Int, statusDescription: String) { 101 | this.statusCode = statusCode 102 | this.statusDescription = statusDescription 103 | } 104 | 105 | fun setStatus(statusCode: StatusCodes, statusDescription: String = statusCode.description) { 106 | this.statusCode = statusCode.code 107 | this.statusDescription = statusDescription 108 | } 109 | 110 | fun setAllowedMethods(allowedMethods: Array) { 111 | addRawHeader("Allow", allowedMethods.map { it.name() }.joinToString(",")) 112 | } 113 | 114 | fun addRawHeader(name: String, value: String) { 115 | if (this.getSupportedHeaderNames().contains(name)) { 116 | throw InvalidHeaderNameException("Setting $name header is not supported here. It should be handled as Response property") 117 | } 118 | 119 | if (value != ""){ 120 | rawHeaders[name] = value 121 | } 122 | } 123 | 124 | fun getHeaders(): List> { 125 | val headerList = mutableListOf( 126 | newHeaderItem("Etag", etag), 127 | newHeaderItem("Location", location), 128 | newHeaderItem("Content-Type", contentType), 129 | newHeaderItem("Connection", connection), 130 | newHeaderItem("Date", convertToDateFormat(DateTime.now()!!)), 131 | newHeaderItem("Cache-Control", cacheControl) 132 | ) 133 | 134 | for (rawHeaderItem in rawHeaders) { 135 | headerList.add(newHeaderItem(rawHeaderItem.key, rawHeaderItem.value)) 136 | } 137 | 138 | contentLength?.let { 139 | headerList.add(newHeaderItem("Content-Length", it.toString())) 140 | } 141 | 142 | lastModified?.let { 143 | headerList.add(newHeaderItem("Last-Modified", convertToDateFormat(it))) 144 | } 145 | 146 | for ((cookieName, cookie) in cookies) { 147 | val cookieString = ServerCookieEncoder.STRICT.encode(cookie) 148 | headerList.add(newHeaderItem("Set-Cookie", cookieString)) 149 | } 150 | 151 | return headerList 152 | } 153 | 154 | fun getCookie(name: String) : Cookie? { 155 | return cookies[name] ?: null 156 | } 157 | 158 | fun setCookie(cookie: Cookie) { 159 | cookies.put(cookie.name(), cookie) 160 | } 161 | 162 | private fun getSupportedHeaderNames() : List { 163 | return listOf( 164 | "Etag", 165 | "Location", 166 | "Content-Type", 167 | "Connection", 168 | "Date", 169 | "Cache-Control", 170 | "Content-Length", 171 | "Last-Modified", 172 | "Set-Cookie" 173 | ) 174 | } 175 | 176 | private fun newHeaderItem(name: String, value: String): AbstractMap.SimpleImmutableEntry { 177 | return AbstractMap.SimpleImmutableEntry(name, value) 178 | } 179 | 180 | fun convertToDateFormat(dateTime: DateTime): String { 181 | val dt = DateTime(dateTime, DateTimeZone.forID("GMT")) 182 | val dtf = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss") 183 | return "${dtf?.print(dt).toString()} GMT" 184 | } 185 | } 186 | 187 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/ContentNegotiationSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.wasabifx.wasabi.protocol.http.StatusCodes 4 | import org.wasabifx.wasabi.routing.with 5 | import kotlin.test.assertEquals 6 | import org.junit.Test as spec 7 | 8 | 9 | class ContentNegotiationSpecs : TestServerContext() { 10 | 11 | @spec fun requesting_charset_should_respond_with_such() { 12 | 13 | val headers = hashMapOf( 14 | "User-Agent" to "test-client", 15 | "Cache-Control" to "max-age=0", 16 | "Accept" to "application/json", 17 | "Accept-Encoding" to "gzip,deflate,sdch", 18 | "Accept-Language" to "en-US,en;q=0.8", 19 | "Accept-Charset" to "utf-8" 20 | ) 21 | 22 | val utf8Test = "Adélaïde" 23 | 24 | TestServer.appServer.get("/customer/9", { 25 | 26 | val obj = object { 27 | val name = utf8Test 28 | val email = utf8Test + "@foo.com" 29 | 30 | } 31 | 32 | response.send(obj) 33 | 34 | }) 35 | 36 | val response = get("http://localhost:${TestServer.definedPort}/customer/9", headers) 37 | 38 | assertEquals(StatusCodes.OK.code,response.statusCode) 39 | assertEquals("{\"name\":\"Adélaïde\",\"email\":\"Adélaïde@foo.com\"}",response.body) 40 | 41 | } 42 | 43 | @spec fun sending_an_object_should_encode_and_send_based_on_contentType() { 44 | 45 | val headers = hashMapOf( 46 | "User-Agent" to "test-client", 47 | "Cache-Control" to "max-age=0", 48 | "Accept" to "application/json", 49 | "Accept-Encoding" to "gzip,deflate,sdch", 50 | "Accept-Language" to "en-US,en;q=0.8", 51 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*;q=0.3" 52 | ) 53 | 54 | TestServer.appServer.get("/customer/10", { 55 | 56 | val obj = object { 57 | val name = "Joe" 58 | val email = "Joe@smith.com" 59 | 60 | } 61 | 62 | response.send(obj) 63 | 64 | }) 65 | 66 | val response = get("http://localhost:${TestServer.definedPort}/customer/10", headers) 67 | 68 | assertEquals(StatusCodes.OK.code,response.statusCode) 69 | assertEquals("{\"name\":\"Joe\",\"email\":\"Joe@smith.com\"}",response.body) 70 | 71 | 72 | 73 | 74 | 75 | } 76 | 77 | @spec fun manual_negotiation_should_execute_correct_body_structure_and_serialize_if_necessary() { 78 | 79 | val headers = hashMapOf( 80 | "User-Agent" to "test-client", 81 | "Cache-Control" to "max-age=0", 82 | "Accept" to "application/json", 83 | "Accept-Encoding" to "gzip,deflate,sdch", 84 | "Accept-Language" to "en-US,en;q=0.8", 85 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*;q=0.3" 86 | 87 | ) 88 | 89 | TestServer.appServer.get("/customer/10", { 90 | 91 | 92 | val obj = object { 93 | val name = "Joe" 94 | val email = "Joe@smith.com" 95 | 96 | } 97 | 98 | response.negotiate( 99 | "text/html".with { send ("this is not the response you're looking for")}, 100 | "application/json" .with { send(obj) } 101 | ) 102 | 103 | }) 104 | 105 | val response = get("http://localhost:${TestServer.definedPort}/customer/10", headers) 106 | 107 | 108 | 109 | assertEquals(StatusCodes.OK.code,response.statusCode) 110 | assertEquals("{\"name\":\"Joe\",\"email\":\"Joe@smith.com\"}",response.body) 111 | 112 | 113 | 114 | 115 | 116 | } 117 | 118 | 119 | @spec fun sending_content_type_when_using_send_should_serialize_using_the_requested_content_type() { 120 | 121 | val headers = hashMapOf( 122 | "User-Agent" to "test-client", 123 | "Cache-Control" to "max-age=0", 124 | "Accept" to "application/json", 125 | "Accept-Encoding" to "gzip,deflate,sdch", 126 | "Accept-Language" to "en-US,en;q=0.8", 127 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*;q=0.3" 128 | 129 | ) 130 | 131 | TestServer.appServer.get("/customer/10", { 132 | 133 | 134 | val obj = object { 135 | val name = "Joe" 136 | val email = "Joe@smith.com" 137 | 138 | } 139 | 140 | response.send(obj, "application/json") 141 | 142 | }) 143 | 144 | val response = get("http://localhost:${TestServer.definedPort}/customer/10", headers) 145 | 146 | assertEquals(StatusCodes.OK.code,response.statusCode) 147 | assertEquals("{\"name\":\"Joe\",\"email\":\"Joe@smith.com\"}",response.body) 148 | 149 | } 150 | 151 | @spec fun should_apply_after_exception_appears() { 152 | TestServer.appServer.exception(NullPointerException::class, { 153 | response.send(exception) 154 | }) 155 | TestServer.appServer.get("/throwException", { throw NullPointerException("Something went wrong") }) 156 | val expectedContentType = "application/json" 157 | val headers = hashMapOf( 158 | "User-Agent" to "test-client", 159 | "Cache-Control" to "max-age=0", 160 | "Accept" to expectedContentType, 161 | "Accept-Encoding" to "gzip,deflate,sdch", 162 | "Accept-Language" to "en-US,en;q=0.8", 163 | "Accept-Charset" to "ISO-8859-1,utf-8;q=0.7,*;q=0.3" 164 | ) 165 | 166 | val response = get("http://localhost:${TestServer.definedPort}/throwException", headers) 167 | 168 | val actualContentType = response.headers.filter { it.name == "Content-Type" } 169 | .first().value.split(";").first() 170 | assertEquals(expectedContentType, actualContentType) 171 | assertEquals("{\"cause\":null", response.body?.substring(0, 13)) 172 | } 173 | 174 | @spec fun returning_content_as_string_with_custom_content_type_must_work() { 175 | TestServer.appServer.get("/json", { response.send("{}", "application/json") }) 176 | TestServer.appServer.get("/xml", { response.send("", "text/xml") }) 177 | 178 | val response = get("http://localhost:${TestServer.definedPort}/json") 179 | assertEquals("application/json", getHeader("Content-Type", response)) 180 | 181 | val response2 = get("http://localhost:${TestServer.definedPort}/xml") 182 | assertEquals("text/xml", getHeader("Content-Type", response2)) 183 | } 184 | 185 | @spec fun setting_explicit_content_type_should_work() { 186 | TestServer.appServer.get("/test", { 187 | response.send("TEST".toByteArray(Charsets.UTF_8), "application/octet-stream" ) 188 | }) 189 | 190 | val response = get("http://localhost:${TestServer.definedPort}/test") 191 | assertEquals("application/octet-stream", getHeader("Content-Type", response)) 192 | } 193 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http/Request.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http 2 | 3 | import io.netty.handler.codec.http.HttpMethod 4 | import io.netty.handler.codec.http.HttpRequest 5 | import io.netty.handler.codec.http.cookie.ServerCookieDecoder 6 | import io.netty.handler.codec.http.multipart.Attribute 7 | import io.netty.handler.codec.http.multipart.InterfaceHttpData 8 | import io.netty.handler.codec.http.multipart.InterfaceHttpData.HttpDataType 9 | import io.netty.handler.codec.http2.Http2Headers 10 | import org.slf4j.LoggerFactory 11 | import org.wasabifx.wasabi.app.configuration 12 | import java.lang.Exception 13 | import java.net.InetSocketAddress 14 | import java.util.* 15 | 16 | class Request() { 17 | 18 | 19 | constructor(httpRequest: HttpRequest, 20 | address: InetSocketAddress) : this() { 21 | this.httpRequest = httpRequest 22 | this.rawHeaders = httpRequest.headers().associate({ it.key.toLowerCase() to it.value }) 23 | this.uri = httpRequest.uri!! 24 | this.method = httpRequest.method!! 25 | this.document = uri.drop(uri.lastIndexOf("/") + 1) 26 | this.path = uri.split('?')[0] 27 | this.scheme = if (configuration!!.sslEnabled) "https" else "http" 28 | this.remoteAddress = address 29 | } 30 | 31 | constructor(http2Headers: Http2Headers?, 32 | address: InetSocketAddress) : this() { 33 | this.http2Headers = http2Headers!! 34 | this.rawHeaders = http2Headers.associate({ it.key.toString().toLowerCase() to it.value.toString() }) 35 | this.uri = http2Headers.path().toString() 36 | this.method = HttpMethod(http2Headers.method().toString()) 37 | this.document = uri.drop(uri.lastIndexOf("/") + 1) 38 | this.path = uri 39 | this.scheme = getHeader("scheme") 40 | this.remoteAddress = address 41 | } 42 | 43 | lateinit var httpRequest: HttpRequest 44 | 45 | lateinit var http2Headers: Http2Headers 46 | 47 | lateinit var uri: String 48 | lateinit var method: HttpMethod 49 | lateinit var rawHeaders: Map 50 | lateinit var document: String 51 | lateinit var path: String 52 | lateinit var scheme: String 53 | lateinit var remoteAddress: InetSocketAddress 54 | lateinit var body: ByteArray 55 | 56 | val host: String by lazy { 57 | getHeader("Host").takeWhile { it != ':' } 58 | } 59 | val protocol: String by lazy { 60 | this.scheme 61 | } 62 | val isSecure: Boolean by lazy { 63 | protocol.compareTo("https", ignoreCase = true) == 0 64 | } 65 | val urlPort: String by lazy { 66 | getHeader("Host").dropWhile { it != ':' }.drop(1) 67 | } 68 | val port: Int by lazy { 69 | if (urlPort != "") urlPort.toInt() else 80 70 | } 71 | val connection: String by lazy { 72 | getHeader("Connection") 73 | } 74 | val cacheControl: String by lazy { 75 | getHeader("Cache-Control") 76 | } 77 | val userAgent: String by lazy { 78 | getHeader("User-Agent") 79 | } 80 | val accept: SortedMap by lazy { 81 | parseAcceptHeader("Accept") 82 | } 83 | val acceptEncoding: SortedMap by lazy { 84 | parseAcceptHeader("Accept-Encoding") 85 | } 86 | val acceptLanguage: SortedMap by lazy { 87 | parseAcceptHeader("Accept-Language") 88 | } 89 | val acceptCharset: SortedMap by lazy { 90 | parseAcceptHeader("Accept-Charset") 91 | } 92 | val ifNoneMatch: String by lazy { 93 | getHeader("If-None-Match") 94 | } 95 | val queryParams: HashMap by lazy { 96 | parseQueryParams() 97 | } 98 | val routeParams: HashMap = HashMap() 99 | val bodyParams: HashMap = HashMap() 100 | val cookies: HashMap by lazy { 101 | parseCookies() 102 | } 103 | val contentType: String by lazy { 104 | getHeader("Content-Type") 105 | } 106 | val chunked: Boolean by lazy { 107 | getHeader("Transfer-Encoding").compareTo("chunked", ignoreCase = true) == 0 108 | } 109 | val authorization: String by lazy { 110 | getHeader("Authorization") 111 | } 112 | 113 | 114 | var session: Session? = null 115 | 116 | // TODO add charset and parse method to split charset from contentType if it exists. 117 | 118 | private fun parseAcceptHeader(header: String): SortedMap { 119 | 120 | val parsed = hashMapOf() 121 | val entries = getHeader(header).split(',') 122 | for (entry in entries) { 123 | val parts = entry.split(';') 124 | val mediaType = parts[0] 125 | var weight = 1 126 | if (parts.size == 2) { 127 | val float = try { 128 | parts[1].trim().drop(2).toFloat() * 10 129 | } catch (exception: Exception) { 130 | 0f 131 | } 132 | weight = float.toInt() 133 | } 134 | parsed.put(mediaType, weight) 135 | } 136 | return parsed.toSortedMap() 137 | } 138 | 139 | private fun getHeader(header: String) = this.rawHeaders[header.toLowerCase()] ?: "" 140 | 141 | private fun parseQueryParams(): HashMap { 142 | val queryParamsList = hashMapOf() 143 | // TODO fix 144 | val urlParams = uri.split('?') 145 | if (urlParams.size == 2) { 146 | val queryNameValuePair = urlParams[1].split("&") 147 | for (entry in queryNameValuePair) { 148 | val nameValuePair = entry.split('=') 149 | if (nameValuePair.size == 2) { 150 | queryParamsList[nameValuePair[0]] = nameValuePair[1] 151 | } else { 152 | queryParamsList[nameValuePair[0]] = "" 153 | } 154 | } 155 | } 156 | return queryParamsList 157 | } 158 | 159 | private fun parseCookies(): HashMap { 160 | val cookieHeader = getHeader("Cookie") 161 | val cookieSet = ServerCookieDecoder.STRICT.decode(cookieHeader) 162 | val cookieList = hashMapOf() 163 | cookieSet?.iterator()?.forEach { cookie -> 164 | val tmpCookie = Cookie(cookie.name().toString(), cookie.value().toString()) 165 | 166 | if (cookie.path() != null) { 167 | tmpCookie.setPath(cookie.path()) 168 | } 169 | 170 | if (cookie.domain() != null) { 171 | tmpCookie.setDomain(cookie.domain()) 172 | } 173 | 174 | tmpCookie.isSecure = cookie.isSecure 175 | cookieList[cookie.name().toString()] = tmpCookie 176 | } 177 | return cookieList 178 | } 179 | 180 | 181 | fun parseBodyParams(httpDataList: MutableList) { 182 | for (entry in httpDataList) { 183 | addBodyParam(entry) 184 | } 185 | } 186 | 187 | fun addBodyParam(httpData: InterfaceHttpData) { 188 | // TODO: Add support for other types of attributes (namely file) 189 | if (httpData.httpDataType == HttpDataType.Attribute) { 190 | val attribute = httpData as Attribute 191 | bodyParams[attribute.name.toString()] = attribute.value.toString() 192 | } 193 | } 194 | } 195 | 196 | 197 | -------------------------------------------------------------------------------- /wasabi-core/src/test/main/kotlin/org/wasabifx/wasabi/test/TestClientSpecs.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.test 2 | 3 | import org.apache.http.message.BasicNameValuePair 4 | import org.junit.Test 5 | import java.util.* 6 | import kotlin.test.assertEquals 7 | 8 | class TestClientSpecs : TestServerContext(){ 9 | 10 | @Test fun test_get_request() { 11 | TestServer.reset() 12 | TestServer.appServer.get("/testget", { 13 | response.send("Correct", "text/plain") 14 | }) 15 | 16 | val client = TestClient(TestServer.appServer) 17 | 18 | assertEquals("Correct", client.sendSimpleRequest("/testget", TestClient.GET).body) 19 | } 20 | 21 | @Test fun test_multiple_get_requests() { 22 | TestServer.reset() 23 | TestServer.appServer.get("/testget", { response.send("Correct", "text/plain") }) 24 | TestServer.appServer.get("/testget2", { response.send("Correct2", "text/plain") }) 25 | 26 | val client = TestClient(TestServer.appServer) 27 | 28 | assertEquals("Correct", client.sendSimpleRequest("/testget", TestClient.GET).body) 29 | assertEquals("Correct2", client.sendSimpleRequest("/testget2", TestClient.GET).body) 30 | } 31 | 32 | @Test fun test_json_post_put_patch_json_string_requests() { 33 | TestServer.reset() 34 | TestServer.appServer.post("/json", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 35 | TestServer.appServer.put("/json", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 36 | TestServer.appServer.patch("/json", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 37 | 38 | val client = TestClient(TestServer.appServer) 39 | 40 | assertEquals("posttest", client.sendJson("/json", TestClient.POST, """{"test":"posttest"}""").body) 41 | assertEquals("puttest", client.sendJson("/json", TestClient.PUT, """{"test":"puttest"}""").body) 42 | assertEquals("patchtest", client.sendJson("/json", TestClient.PATCH, """{"test":"patchtest"}""").body) 43 | } 44 | 45 | @Test fun test_json_post_put_patch_json_hashmap_requests() { 46 | TestServer.reset() 47 | TestServer.appServer.post("/json", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 48 | TestServer.appServer.put("/json", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 49 | TestServer.appServer.patch("/json", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 50 | 51 | val client = TestClient(TestServer.appServer) 52 | 53 | assertEquals("posttest", client.sendJson("/json", TestClient.POST, hashMapOf("test" to "posttest")).body) 54 | assertEquals("puttest", client.sendJson("/json", TestClient.PUT, hashMapOf("test" to "puttest")).body) 55 | assertEquals("patchtest", client.sendJson("/json", TestClient.PATCH, hashMapOf("test" to "patchtest")).body) 56 | } 57 | 58 | @Test fun test_json_post_json_multilevel_hashmap_request() { 59 | TestServer.reset() 60 | TestServer.appServer.post("/json", { response.send(request.bodyParams["test"] ?: "key not found", "application/json") }) 61 | 62 | val client = TestClient(TestServer.appServer) 63 | 64 | assertEquals("""{"another":"weeee"}""", client.sendJson("/json", TestClient.POST, hashMapOf("test" to hashMapOf("another" to "weeee"))).body) 65 | } 66 | 67 | @Test fun test_form_post_put_patch_arraylist_requests() { 68 | TestServer.reset() 69 | TestServer.appServer.post("/form", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 70 | TestServer.appServer.put("/form", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 71 | TestServer.appServer.patch("/form", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 72 | 73 | val client = TestClient(TestServer.appServer) 74 | 75 | val postFormFields = ArrayList() 76 | postFormFields.add(BasicNameValuePair("test", "posttest")) 77 | 78 | val putFormFields = ArrayList() 79 | putFormFields.add(BasicNameValuePair("test", "puttest")) 80 | 81 | val patchFormFields = ArrayList() 82 | patchFormFields.add(BasicNameValuePair("test", "patchtest")) 83 | 84 | assertEquals("posttest", client.sendForm("/form", TestClient.POST, postFormFields).body) 85 | assertEquals("puttest", client.sendForm("/form", TestClient.PUT, putFormFields).body) 86 | assertEquals("patchtest", client.sendForm("/form", TestClient.PATCH, patchFormFields).body) 87 | } 88 | 89 | @Test fun test_form_post_put_patch_hashmap_requests() { 90 | TestServer.reset() 91 | TestServer.appServer.post("/form", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 92 | TestServer.appServer.put("/form", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 93 | TestServer.appServer.patch("/form", { response.send(request.bodyParams["test"] ?: "key not found", "text/plain") }) 94 | 95 | val client = TestClient(TestServer.appServer) 96 | 97 | assertEquals("posttest", client.sendForm("/form", TestClient.POST, hashMapOf("test" to "posttest")).body) 98 | assertEquals("puttest", client.sendForm("/form", TestClient.PUT, hashMapOf("test" to "puttest")).body) 99 | assertEquals("patchtest", client.sendForm("/form", TestClient.PATCH, hashMapOf("test" to "patchtest")).body) 100 | } 101 | 102 | @Test fun test_form_get_hashmap_request() { 103 | TestServer.reset() 104 | TestServer.appServer.get("/form", { response.send(request.queryParams["test"] ?: "key not found", "text/plain") }) 105 | 106 | val client = TestClient(TestServer.appServer) 107 | 108 | var exceptionThrown = false 109 | 110 | try { 111 | client.sendForm("/form", TestClient.GET, hashMapOf("test" to "gettest")) 112 | } catch (e: Exception) { 113 | exceptionThrown = true 114 | } 115 | 116 | assertEquals(true, exceptionThrown) 117 | } 118 | 119 | @Test fun test_delete_options_requests() { 120 | TestServer.reset() 121 | TestServer.appServer.delete("/resource", { response.send("delete method", "text/plain") }) 122 | TestServer.appServer.options("/resource", { response.send("options method", "text/plain") }) 123 | TestServer.appServer.head("/resource", { response.send("head method", "text/plain") }) 124 | 125 | val client = TestClient(TestServer.appServer) 126 | 127 | assertEquals("delete method", client.sendSimpleRequest("/resource", TestClient.DELETE).body) 128 | assertEquals("options method", client.sendSimpleRequest("/resource", TestClient.OPTIONS).body) 129 | assertEquals(null, client.sendSimpleRequest("/resource", TestClient.HEAD).body) 130 | } 131 | 132 | @Test fun test_additional_header_requests() { 133 | TestServer.reset() 134 | TestServer.appServer.get("/resource", { response.send(request.acceptCharset.toString(), "text/plain") }) 135 | TestServer.appServer.post("/resource", { response.send(request.acceptCharset.toString(), "text/plain") }) 136 | 137 | val client = TestClient(TestServer.appServer) 138 | 139 | assertEquals("{UTF-8=1}", client.sendSimpleRequest("/resource", TestClient.GET, hashMapOf("Accept-Charset" to "UTF-8")).body) 140 | assertEquals("{UTF-8=1}", client.sendForm("/resource", TestClient.POST, hashMapOf("field" to "value"), hashMapOf("Accept-Charset" to "UTF-8")).body) 141 | } 142 | } -------------------------------------------------------------------------------- /wasabi-core/src/main/kotlin/org/wasabifx/wasabi/protocol/http2/Http2Handler.kt: -------------------------------------------------------------------------------- 1 | package org.wasabifx.wasabi.protocol.http2 2 | 3 | import io.netty.buffer.ByteBuf 4 | import io.netty.buffer.Unpooled 5 | import io.netty.channel.ChannelHandlerContext 6 | import io.netty.handler.codec.http.HttpMethod 7 | import io.netty.handler.codec.http.HttpServerUpgradeHandler 8 | import io.netty.handler.codec.http2.* 9 | import io.netty.util.CharsetUtil 10 | import org.slf4j.LoggerFactory 11 | import org.wasabifx.wasabi.app.AppServer 12 | import org.wasabifx.wasabi.interceptors.InterceptOn 13 | import org.wasabifx.wasabi.interceptors.InterceptorEntry 14 | import org.wasabifx.wasabi.protocol.http.Request 15 | import org.wasabifx.wasabi.protocol.http.Response 16 | import org.wasabifx.wasabi.protocol.http.StatusCodes 17 | import org.wasabifx.wasabi.routing.PatternAndVerbMatchingRouteLocator 18 | import org.wasabifx.wasabi.routing.Route 19 | import org.wasabifx.wasabi.routing.RouteHandler 20 | import java.net.InetSocketAddress 21 | 22 | class Http2Handler(val appServer: AppServer, decoder: Http2ConnectionDecoder, encoder: Http2ConnectionEncoder, settings: Http2Settings) : Http2ConnectionHandler(decoder, encoder, settings), Http2FrameListener { 23 | 24 | private val log = LoggerFactory.getLogger(Http2Handler::class.java) 25 | 26 | // TODO Assess the best place to implement the prerequest interceptor execution. 27 | val preRequestInterceptors = appServer.interceptors.filter { it.interceptOn == InterceptOn.PreRequest } 28 | val preExecutionInterceptors = appServer.interceptors.filter { it.interceptOn == InterceptOn.PreExecution } 29 | val postExecutionInterceptors = appServer.interceptors.filter { it.interceptOn == InterceptOn.PostExecution } 30 | val postRequestInterceptors = appServer.interceptors.filter { it.interceptOn == InterceptOn.PostRequest } 31 | val errorInterceptors = appServer.interceptors.filter { it.interceptOn == InterceptOn.Error } 32 | 33 | var currentSettings : Http2Settings? = null 34 | 35 | val requests: MutableMap = hashMapOf() 36 | val responses: MutableMap = hashMapOf() 37 | 38 | // TODO implement proper use of windowsize and settings 39 | 40 | private var bypassPipeline = false 41 | 42 | private fun executePipeline(streamId: Int, request: Request, ctx: ChannelHandlerContext) { 43 | val routeHandlers = appServer.routeLocator.findRouteHandlers(request.path, HttpMethod(request.method.name())) 44 | 45 | // process the route specific pre execution interceptors 46 | runInterceptors(streamId, preExecutionInterceptors, routeHandlers) 47 | 48 | // Execute the handlers for this route. 49 | runHandlers(streamId, routeHandlers) 50 | 51 | // process the route specific post execution interceptors 52 | runInterceptors(streamId, postExecutionInterceptors, routeHandlers) 53 | 54 | // Run global interceptors again 55 | runInterceptors(streamId, postExecutionInterceptors) 56 | 57 | writeResponse(ctx, streamId, responses[streamId]!!) 58 | } 59 | 60 | private fun writeResponse(ctx: ChannelHandlerContext?, streamId: Int, response: Response) { 61 | // If we have a non successful status make sure the error interceptors know about it. 62 | if (response.statusCode / 100 == 4 || response.statusCode / 100 == 5) { 63 | runInterceptors(streamId, errorInterceptors) 64 | } 65 | 66 | var buffer = "" 67 | if (response.sendBuffer == null) { 68 | buffer = response.statusDescription 69 | } else if (response.sendBuffer is String) { 70 | if (response.sendBuffer as String != "") { 71 | buffer = (response.sendBuffer as String) 72 | } else { 73 | buffer = response.statusDescription 74 | } 75 | } else { 76 | if (response.negotiatedMediaType != "") { 77 | val serializer = appServer.serializers.firstOrNull { it.canSerialize(response.negotiatedMediaType) } 78 | if (serializer != null) { 79 | response.contentType = response.negotiatedMediaType 80 | buffer = serializer.serialize(response.sendBuffer!!) 81 | } else { 82 | response.setStatus(StatusCodes.UnsupportedMediaType) 83 | } 84 | } 85 | } 86 | 87 | runInterceptors(streamId, postRequestInterceptors) 88 | 89 | val headers = DefaultHttp2Headers().status(response.statusCode.toString()) 90 | encoder().writeHeaders(ctx, streamId, headers, 0, false, ctx!!.newPromise()) 91 | encoder().writeData(ctx, streamId, Unpooled.copiedBuffer(buffer, CharsetUtil.UTF_8), 0, true, ctx.newPromise()) 92 | ctx.flush() 93 | } 94 | 95 | private fun runHandlers(streamId: Int, routeHandlers : Route) 96 | { 97 | val request = this.requests[streamId] 98 | val response = Response() 99 | 100 | // Assign to collection for later use. 101 | this.responses.put(streamId, response) 102 | 103 | // If the flag has been set no-op to allow the response to be flushed as is. 104 | if (bypassPipeline) 105 | { 106 | return 107 | } 108 | for (handler in routeHandlers.handler) { 109 | 110 | val handlerExtension : RouteHandler.() -> Unit = handler 111 | val routeHandler = RouteHandler(request!!, response) 112 | 113 | routeHandler.handlerExtension() 114 | if (!routeHandler.executeNext) { 115 | break 116 | } 117 | } 118 | } 119 | 120 | private fun runInterceptors(streamId: Int, interceptors: List, route: Route? = null) { 121 | // If the flag has been set no-op to allow the response to be flushed as is. 122 | if (bypassPipeline) 123 | { 124 | return 125 | } 126 | var interceptorsToRun : List 127 | if (route == null) { 128 | interceptorsToRun = interceptors.filter { it.path == "*" } 129 | } else { 130 | interceptorsToRun = interceptors.filter { appServer.routeLocator.compareRouteSegments(route, it.path) } 131 | } 132 | for ((interceptor) in interceptorsToRun) { 133 | 134 | val executeNext = interceptor.intercept(requests[streamId]!!, responses[streamId]!!) 135 | 136 | if (!executeNext) { 137 | bypassPipeline = true 138 | break 139 | } 140 | } 141 | } 142 | 143 | override fun userEventTriggered(ctx: ChannelHandlerContext?, evt: Any?) { 144 | if (evt is HttpServerUpgradeHandler.UpgradeEvent) { 145 | log.debug("HTTP/2 upgrade requested") 146 | // If we get a non SSL HTTP/2 upgrade Write a response to the upgrade request 147 | val headers = DefaultHttp2Headers().status(StatusCodes.OK.code.toString()) 148 | encoder().writeHeaders(ctx, 1, headers, 0, true, ctx!!.newPromise()) 149 | } 150 | super.userEventTriggered(ctx, evt) 151 | } 152 | 153 | override fun onPingRead(ctx: ChannelHandlerContext?, data: ByteBuf?) { 154 | // Handled by Netty 155 | } 156 | 157 | override fun onDataRead(ctx: ChannelHandlerContext?, streamId: Int, data: ByteBuf?, padding: Int, endOfStream: Boolean): Int { 158 | val processed = data!!.readableBytes() + padding 159 | try{ 160 | val request = requests[streamId] 161 | if (endOfStream) { 162 | executePipeline(streamId, request!!, ctx!!) 163 | } 164 | } 165 | catch(exception: Exception) 166 | { 167 | log.error(exception.message) 168 | } 169 | 170 | return processed 171 | } 172 | 173 | override fun onSettingsRead(ctx: ChannelHandlerContext?, settings: Http2Settings?) { 174 | this.currentSettings = settings 175 | } 176 | 177 | override fun onUnknownFrame(ctx: ChannelHandlerContext?, frameType: Byte, streamId: Int, flags: Http2Flags?, payload: ByteBuf?) { 178 | log.debug("onUnknownFrame") 179 | } 180 | 181 | override fun onHeadersRead(ctx: ChannelHandlerContext?, streamId: Int, headers: Http2Headers?, padding: Int, endOfStream: Boolean) { 182 | // TODO work out use case of the simplified overload. 183 | log.debug("onHeadersRead") 184 | log.debug(headers.toString()) 185 | } 186 | 187 | override fun onHeadersRead(ctx: ChannelHandlerContext?, streamId: Int, headers: Http2Headers?, streamDependency: Int, weight: Short, exclusive: Boolean, padding: Int, endOfStream: Boolean) { 188 | log.debug(headers.toString()) 189 | val request = Request(headers, ctx!!.channel().remoteAddress() as InetSocketAddress) 190 | requests[streamId] = request 191 | if (endOfStream || request.method.toString() == "GET") { 192 | try { 193 | executePipeline(streamId, request, ctx) 194 | } 195 | catch(exception : Exception) { 196 | log.error(exception.message) 197 | } 198 | } 199 | } 200 | 201 | override fun onPushPromiseRead(ctx: ChannelHandlerContext?, streamId: Int, promisedStreamId: Int, headers: Http2Headers?, padding: Int) { 202 | log.debug("onPushPromiseRead") 203 | } 204 | 205 | override fun onPingAckRead(ctx: ChannelHandlerContext?, data: ByteBuf?) { 206 | log.debug("onPingAckRead") 207 | } 208 | 209 | override fun onRstStreamRead(ctx: ChannelHandlerContext?, streamId: Int, errorCode: Long) { 210 | log.debug("onRstStreamRead") 211 | } 212 | 213 | override fun onPriorityRead(ctx: ChannelHandlerContext?, streamId: Int, streamDependency: Int, weight: Short, exclusive: Boolean) { 214 | log.debug("onPriorityRead") 215 | } 216 | 217 | override fun onGoAwayRead(ctx: ChannelHandlerContext?, lastStreamId: Int, errorCode: Long, debugData: ByteBuf?) { 218 | log.debug("onGoAwayRead") 219 | } 220 | 221 | override fun onWindowUpdateRead(ctx: ChannelHandlerContext?, streamId: Int, windowSizeIncrement: Int) { 222 | log.debug("onWindowUpdateRead") 223 | } 224 | 225 | override fun onSettingsAckRead(ctx: ChannelHandlerContext?) { 226 | log.debug("HTTP2 Settings acknowledged") 227 | 228 | } 229 | 230 | override fun exceptionCaught(ctx: ChannelHandlerContext?, cause: Throwable?) { 231 | super.onError(ctx, cause) 232 | super.exceptionCaught(ctx, cause) 233 | log.error("Exception Caught: $cause") 234 | cause!!.printStackTrace() 235 | ctx!!.close() 236 | } 237 | } --------------------------------------------------------------------------------