diff options
author | Li Haoyi <haoyi.sg@gmail.com> | 2018-08-13 03:54:50 +0800 |
---|---|---|
committer | Li Haoyi <haoyi.sg@gmail.com> | 2018-08-13 03:54:50 +0800 |
commit | 2fc9fd22084bb4a89a72be525c18fc409303ada5 (patch) | |
tree | 511734c255237e99ba0b5b302232073572faaa38 | |
parent | 790deda0f38e36c7378ff05a9c234a56e14a5d6b (diff) | |
download | cask-2fc9fd22084bb4a89a72be525c18fc409303ada5.tar.gz cask-2fc9fd22084bb4a89a72be525c18fc409303ada5.tar.bz2 cask-2fc9fd22084bb4a89a72be525c18fc409303ada5.zip |
Basic websocket support works
-rw-r--r-- | build.sc | 3 | ||||
-rw-r--r-- | cask/src/cask/endpoints/FormEndpoint.scala | 4 | ||||
-rw-r--r-- | cask/src/cask/endpoints/JsonEndpoint.scala | 5 | ||||
-rw-r--r-- | cask/src/cask/endpoints/StaticEndpoints.scala | 6 | ||||
-rw-r--r-- | cask/src/cask/endpoints/WebEndpoints.scala | 6 | ||||
-rw-r--r-- | cask/src/cask/endpoints/WebSocketEndpoint.scala | 52 | ||||
-rw-r--r-- | cask/src/cask/internal/Router.scala | 2 | ||||
-rw-r--r-- | cask/src/cask/main/Decorators.scala | 13 | ||||
-rw-r--r-- | cask/src/cask/main/Main.scala | 100 | ||||
-rw-r--r-- | cask/src/cask/main/Routes.scala | 10 | ||||
-rw-r--r-- | cask/src/cask/model/Params.scala | 1 | ||||
-rw-r--r-- | cask/src/cask/package.scala | 6 | ||||
-rw-r--r-- | docs/pages/1 - Cask: a Scala HTTP micro-framework .md | 20 | ||||
-rw-r--r-- | example/endpoints/app/src/Endpoints.scala | 5 | ||||
-rw-r--r-- | example/websockets/app/src/Websockets.scala | 29 | ||||
-rw-r--r-- | example/websockets/app/test/src/ExampleTests.scala | 47 | ||||
-rw-r--r-- | example/websockets/build.sc | 19 |
17 files changed, 275 insertions, 53 deletions
@@ -19,6 +19,7 @@ import $file.example.todo.build import $file.example.todoApi.build import $file.example.todoDb.build import $file.example.variableRoutes.build +import $file.example.websockets.build object cask extends ScalaModule with PublishModule { def scalaVersion = "2.12.6" @@ -80,6 +81,7 @@ object example extends Module{ object todoApi extends $file.example.todoApi.build.AppModule with LocalModule object todoDb extends $file.example.todoDb.build.AppModule with LocalModule object variableRoutes extends $file.example.variableRoutes.build.AppModule with LocalModule + object websockets extends $file.example.websockets.build.AppModule with LocalModule } def publishVersion = T.input($file.ci.version.publishVersion) @@ -121,6 +123,7 @@ def uploadToGithub(authKey: String) = T.command{ $file.example.todoApi.build.millSourcePath, $file.example.todoDb.build.millSourcePath, $file.example.variableRoutes.build.millSourcePath, + $file.example.websockets.build.millSourcePath, ) for(example <- examples){ val f = tmp.dir() diff --git a/cask/src/cask/endpoints/FormEndpoint.scala b/cask/src/cask/endpoints/FormEndpoint.scala index 48190ce..eb882fa 100644 --- a/cask/src/cask/endpoints/FormEndpoint.scala +++ b/cask/src/cask/endpoints/FormEndpoint.scala @@ -1,7 +1,7 @@ package cask.endpoints import cask.internal.{Router, Util} -import cask.main.{Endpoint, Routes} +import cask.main.{Endpoint, HttpDecorator, Routes} import cask.model._ import io.undertow.server.handlers.form.FormParserFactory @@ -43,7 +43,7 @@ object FormReader{ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input.map(_.asInstanceOf[FormFile]) } } -class postForm(val path: String, override val subpath: Boolean = false) extends Endpoint{ +class postForm(val path: String, override val subpath: Boolean = false) extends Endpoint with HttpDecorator{ type Output = Response val methods = Seq("post") diff --git a/cask/src/cask/endpoints/JsonEndpoint.scala b/cask/src/cask/endpoints/JsonEndpoint.scala index 51199c3..f3b0cae 100644 --- a/cask/src/cask/endpoints/JsonEndpoint.scala +++ b/cask/src/cask/endpoints/JsonEndpoint.scala @@ -4,7 +4,7 @@ import java.io.ByteArrayOutputStream import cask.internal.{Router, Util} import cask.internal.Router.EntryPoint -import cask.main.{Endpoint, Routes} +import cask.main.{Endpoint, HttpDecorator, Routes} import cask.model.{ParamContext, Response} @@ -26,12 +26,11 @@ object JsReader{ } } } -class postJson(val path: String, override val subpath: Boolean = false) extends Endpoint{ +class postJson(val path: String, override val subpath: Boolean = false) extends Endpoint with HttpDecorator{ type Output = Response val methods = Seq("post") type Input = ujson.Js.Value type InputParser[T] = JsReader[T] - def wrapFunction(ctx: ParamContext, delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { val obj = for{ diff --git a/cask/src/cask/endpoints/StaticEndpoints.scala b/cask/src/cask/endpoints/StaticEndpoints.scala index e93c09b..a9b3193 100644 --- a/cask/src/cask/endpoints/StaticEndpoints.scala +++ b/cask/src/cask/endpoints/StaticEndpoints.scala @@ -1,10 +1,10 @@ package cask.endpoints import cask.internal.Router -import cask.main.Endpoint -import cask.model.{Response, ParamContext} +import cask.main.{Endpoint, HttpDecorator} +import cask.model.{ParamContext, Response} -class static(val path: String) extends Endpoint { +class static(val path: String) extends Endpoint with HttpDecorator{ type Output = String val methods = Seq("get") type Input = Seq[String] diff --git a/cask/src/cask/endpoints/WebEndpoints.scala b/cask/src/cask/endpoints/WebEndpoints.scala index 2125b4d..41c3113 100644 --- a/cask/src/cask/endpoints/WebEndpoints.scala +++ b/cask/src/cask/endpoints/WebEndpoints.scala @@ -1,13 +1,13 @@ package cask.endpoints import cask.internal.Router -import cask.main.Endpoint -import cask.model.{Response, ParamContext} +import cask.main.{Endpoint, HttpDecorator} +import cask.model.{ParamContext, Response} import collection.JavaConverters._ -trait WebEndpoint extends Endpoint{ +trait WebEndpoint extends Endpoint with HttpDecorator{ type Output = Response type Input = Seq[String] type InputParser[T] = QueryParamReader[T] diff --git a/cask/src/cask/endpoints/WebSocketEndpoint.scala b/cask/src/cask/endpoints/WebSocketEndpoint.scala new file mode 100644 index 0000000..a795afd --- /dev/null +++ b/cask/src/cask/endpoints/WebSocketEndpoint.scala @@ -0,0 +1,52 @@ +package cask.endpoints + +import cask.internal.Router +import cask.model.{ParamContext, Subpath} +import io.undertow.server.HttpServerExchange +import io.undertow.websockets.WebSocketConnectionCallback +trait WebsocketParam[T] extends Router.ArgReader[Seq[String], T, cask.model.ParamContext] + +object WebsocketParam{ + class NilParam[T](f: (ParamContext, String) => T) extends WebsocketParam[T]{ + def arity = 0 + def read(ctx: ParamContext, label: String, v: Seq[String]): T = f(ctx, label) + } + implicit object HttpExchangeParam extends NilParam[HttpServerExchange]( + (ctx, label) => ctx.exchange + ) + implicit object SubpathParam extends NilParam[Subpath]( + (ctx, label) => new Subpath(ctx.remaining) + ) + class SimpleParam[T](f: String => T) extends WebsocketParam[T]{ + def arity = 1 + def read(ctx: cask.model.ParamContext, label: String, v: Seq[String]): T = f(v.head) + } + + implicit object StringParam extends SimpleParam[String](x => x) + implicit object BooleanParam extends SimpleParam[Boolean](_.toBoolean) + implicit object ByteParam extends SimpleParam[Byte](_.toByte) + implicit object ShortParam extends SimpleParam[Short](_.toShort) + implicit object IntParam extends SimpleParam[Int](_.toInt) + implicit object LongParam extends SimpleParam[Long](_.toLong) + implicit object DoubleParam extends SimpleParam[Double](_.toDouble) + implicit object FloatParam extends SimpleParam[Float](_.toFloat) +} + +sealed trait WebsocketResult +object WebsocketResult{ + implicit class Response(val value: cask.model.Response) extends WebsocketResult + implicit class Listener(val value: WebSocketConnectionCallback) extends WebsocketResult +} + +class websocket(val path: String, subpath: Boolean = false) extends cask.main.BaseEndpoint{ + type Output = WebsocketResult + val methods = Seq("websocket") + type Input = Seq[String] + type InputParser[T] = WebsocketParam[T] + type Returned = Router.Result[WebsocketResult] + def wrapFunction(ctx: ParamContext, delegate: Delegate): Returned = delegate(Map()) + + def wrapPathSegment(s: String): Input = Seq(s) + + +} diff --git a/cask/src/cask/internal/Router.scala b/cask/src/cask/internal/Router.scala index 36976af..9fa9b60 100644 --- a/cask/src/cask/internal/Router.scala +++ b/cask/src/cask/internal/Router.scala @@ -204,7 +204,7 @@ class Router[C <: Context](val c: C) { def extractMethod(method: MethodSymbol, curCls: c.universe.Type, convertToResultType: c.Tree, - ctx: c.Type, + ctx: c.Tree, argReaders: Seq[c.Tree], annotDeserializeTypes: Seq[c.Tree]): c.universe.Tree = { val baseArgSym = TermName(c.freshName()) diff --git a/cask/src/cask/main/Decorators.scala b/cask/src/cask/main/Decorators.scala index 73d8c19..239cab4 100644 --- a/cask/src/cask/main/Decorators.scala +++ b/cask/src/cask/main/Decorators.scala @@ -2,13 +2,15 @@ package cask.main import cask.internal.Router import cask.internal.Router.ArgReader -import cask.model.{Response, ParamContext} +import cask.model.{ParamContext, Response} + +trait Endpoint extends BaseEndpoint with HttpDecorator /** * Used to annotate a single Cask endpoint function; similar to a [[Decorator]] * but with additional metadata and capabilities. */ -trait Endpoint extends BaseDecorator{ +trait BaseEndpoint extends BaseDecorator{ /** * What is the path that this particular endpoint matches? */ @@ -45,10 +47,13 @@ trait BaseDecorator{ type InputParser[T] <: ArgReader[Input, T, ParamContext] type Output type Delegate = Map[String, Input] => Router.Result[Output] - type Returned = Router.Result[Response] + type Returned <: Router.Result[Any] def wrapFunction(ctx: ParamContext, delegate: Delegate): Returned def getParamParser[T](implicit p: InputParser[T]) = p } +trait HttpDecorator extends BaseDecorator{ + type Returned = Router.Result[Response] +} /** * A decorator allows you to annotate a function to wrap it, via @@ -61,7 +66,7 @@ trait BaseDecorator{ * to `wrapFunction`, which takes a `Map` representing any additional argument * lists (if any). */ -trait Decorator extends BaseDecorator { +trait Decorator extends HttpDecorator { type Input = Any type Output = Response diff --git a/cask/src/cask/main/Main.scala b/cask/src/cask/main/Main.scala index 87c66c4..94d0e14 100644 --- a/cask/src/cask/main/Main.scala +++ b/cask/src/cask/main/Main.scala @@ -1,5 +1,6 @@ package cask.main +import cask.endpoints.WebsocketResult import cask.model._ import cask.internal.Router.EntryPoint import cask.internal.{DispatchTrie, Router, Util} @@ -27,7 +28,7 @@ abstract class BaseMain{ } yield (routes, route) - lazy val routeTries = Seq("get", "put", "post") + lazy val routeTries = Seq("get", "put", "post", "websocket") .map { method => method -> DispatchTrie.construct[(Routes, Routes.EndpointMetadata[_])](0, for ((route, metadata) <- routeList if metadata.endpoint.methods.contains(method)) @@ -52,42 +53,81 @@ abstract class BaseMain{ ) } + def genericWebsocketHandler(exchange0: HttpServerExchange) = + hello(exchange0, "websocket", ParamContext(exchange0, _), exchange0.getRequestPath).foreach{ r => + r.asInstanceOf[WebsocketResult] match{ + case l: WebsocketResult.Listener => + io.undertow.Handlers.websocket(l.value).handleRequest(exchange0) + case r: WebsocketResult.Response => + writeResponseHandler(r).handleRequest(exchange0) + } + } - def defaultHandler = new BlockingHandler( + def defaultHandler = new HttpHandler() { def handleRequest(exchange: HttpServerExchange): Unit = { - routeTries(exchange.getRequestMethod.toString.toLowerCase()).lookup(Util.splitPath(exchange.getRequestPath).toList, Map()) match{ - case None => writeResponse(exchange, handleNotFound()) - case Some(((routes, metadata), extBindings, remaining)) => - val ctx = ParamContext(exchange, remaining) - def rec(remaining: List[Decorator], - bindings: List[Map[String, Any]]): Router.Result[Response] = try { - remaining match { - case head :: rest => - head.wrapFunction(ctx, args => rec(rest, args :: bindings)) - - case Nil => - metadata.endpoint.wrapFunction(ctx, epBindings => - metadata.entryPoint - .asInstanceOf[EntryPoint[cask.main.Routes, cask.model.ParamContext]] - .invoke(routes, ctx, (epBindings ++ extBindings.mapValues(metadata.endpoint.wrapPathSegment)) :: bindings.reverse) - .asInstanceOf[Router.Result[Nothing]] - ) - - } - // Make sure we wrap any exceptions that bubble up from decorator - // bodies, so outer decorators do not need to worry about their - // delegate throwing on them - }catch{case e: Throwable => Router.Result.Error.Exception(e) } - - rec((metadata.decorators ++ routes.decorators ++ mainDecorators).toList, Nil)match{ - case Router.Result.Success(response: Response) => writeResponse(exchange, response) - case e: Router.Result.Error => writeResponse(exchange, handleEndpointError(exchange, routes, metadata, e)) - } + if (exchange.getRequestHeaders.getFirst("Upgrade") == "websocket") { + + genericWebsocketHandler(exchange) + } else { + defaultHttpHandler.handleRequest(exchange) } } } + + def writeResponseHandler(r: WebsocketResult.Response) = new BlockingHandler( + new HttpHandler { + def handleRequest(exchange: HttpServerExchange): Unit = { + writeResponse(exchange, r.value) + } + } ) + def defaultHttpHandler = new BlockingHandler( + new HttpHandler() { + def handleRequest(exchange: HttpServerExchange) = { + hello(exchange, exchange.getRequestMethod.toString.toLowerCase(), ParamContext(exchange, _), exchange.getRequestPath).foreach{ r => + writeResponse(exchange, r.asInstanceOf[Response]) + } + } + } + ) + + def hello(exchange0: HttpServerExchange, effectiveMethod: String, ctx0: Seq[String] => ParamContext, path: String) = { + routeTries(effectiveMethod).lookup(Util.splitPath(path).toList, Map()) match{ + case None => + writeResponse(exchange0, handleNotFound()) + None + case Some(((routes, metadata), extBindings, remaining)) => + val ctx = ParamContext(exchange0, remaining) + val ctx1 = ctx0(remaining) + def rec(remaining: List[Decorator], + bindings: List[Map[String, Any]]): Router.Result[Any] = try { + remaining match { + case head :: rest => + head.wrapFunction(ctx, args => rec(rest, args :: bindings).asInstanceOf[Router.Result[head.Output]]) + + case Nil => + metadata.endpoint.wrapFunction(ctx, epBindings => + metadata.entryPoint + .asInstanceOf[EntryPoint[cask.main.Routes, cask.model.ParamContext]] + .invoke(routes, ctx1, (epBindings ++ extBindings.mapValues(metadata.endpoint.wrapPathSegment)) :: bindings.reverse) + .asInstanceOf[Router.Result[Nothing]] + ) + } + // Make sure we wrap any exceptions that bubble up from decorator + // bodies, so outer decorators do not need to worry about their + // delegate throwing on them + }catch{case e: Throwable => Router.Result.Error.Exception(e) } + + rec((metadata.decorators ++ routes.decorators ++ mainDecorators).toList, Nil)match{ + case Router.Result.Success(res) => Some(res) + case e: Router.Result.Error => + writeResponse(exchange0, handleEndpointError(exchange0, routes, metadata, e)) + None + } + } + + } def handleEndpointError(exchange: HttpServerExchange, routes: Routes, diff --git a/cask/src/cask/main/Routes.scala b/cask/src/cask/main/Routes.scala index 7b47731..aaec832 100644 --- a/cask/src/cask/main/Routes.scala +++ b/cask/src/cask/main/Routes.scala @@ -8,8 +8,8 @@ import language.experimental.macros object Routes{ case class EndpointMetadata[T](decorators: Seq[Decorator], - endpoint: Endpoint, - entryPoint: EntryPoint[T, ParamContext]) + endpoint: BaseEndpoint, + entryPoint: EntryPoint[T, _]) case class RoutesEndpointsMetadata[T](value: EndpointMetadata[T]*) object RoutesEndpointsMetadata{ implicit def initialize[T] = macro initializeImpl[T] @@ -22,12 +22,12 @@ object Routes{ val annotations = m.annotations.filter(_.tree.tpe <:< c.weakTypeOf[BaseDecorator]).reverse if annotations.nonEmpty } yield { - if(!(annotations.head.tree.tpe <:< weakTypeOf[Endpoint])) c.abort( + if(!(annotations.head.tree.tpe <:< weakTypeOf[BaseEndpoint])) c.abort( annotations.head.tree.pos, s"Last annotation applied to a function must be an instance of Endpoint, " + s"not ${annotations.head.tree.tpe}" ) - val allEndpoints = annotations.filter(_.tree.tpe <:< weakTypeOf[Endpoint]) + val allEndpoints = annotations.filter(_.tree.tpe <:< weakTypeOf[BaseEndpoint]) if(allEndpoints.length > 1) c.abort( annotations.head.tree.pos, s"You can only apply one Endpoint annotation to a function, not " + @@ -43,7 +43,7 @@ object Routes{ m.asInstanceOf[MethodSymbol], weakTypeOf[T], q"${annotObjectSyms.head}.convertToResultType", - c.weakTypeOf[ParamContext], + tq"cask.ParamContext", annotObjectSyms.map(annotObjectSym => q"$annotObjectSym.getParamParser"), annotObjectSyms.map(annotObjectSym => tq"$annotObjectSym.Input") diff --git a/cask/src/cask/model/Params.scala b/cask/src/cask/model/Params.scala index 27c1a68..bd10161 100644 --- a/cask/src/cask/model/Params.scala +++ b/cask/src/cask/model/Params.scala @@ -6,6 +6,7 @@ import cask.endpoints.ParamReader.NilParam import cask.internal.Util import io.undertow.server.HttpServerExchange import io.undertow.server.handlers.CookieImpl +import io.undertow.websockets.spi.WebSocketHttpExchange class Subpath(val value: Seq[String]) object Subpath{ diff --git a/cask/src/cask/package.scala b/cask/src/cask/package.scala index 19dc675..b1a1550 100644 --- a/cask/src/cask/package.scala +++ b/cask/src/cask/package.scala @@ -22,6 +22,10 @@ package object cask { val ParamContext = model.ParamContext // endpoints + type websocket = endpoints.websocket + val WebsocketResult = endpoints.WebsocketResult + type WebsocketResult = endpoints.WebsocketResult + type get = endpoints.get type post = endpoints.post type put = endpoints.put @@ -37,6 +41,6 @@ package object cask { type Main = main.Main type Decorator = main.Decorator type Endpoint = main.Endpoint - type BaseDecorator = main.BaseDecorator + type BaseDecorator = main.HttpDecorator } diff --git a/docs/pages/1 - Cask: a Scala HTTP micro-framework .md b/docs/pages/1 - Cask: a Scala HTTP micro-framework .md index db35e6b..131d06b 100644 --- a/docs/pages/1 - Cask: a Scala HTTP micro-framework .md +++ b/docs/pages/1 - Cask: a Scala HTTP micro-framework .md @@ -299,6 +299,26 @@ Or globally, in your `cask.Main`: $$$compress3 +### Websockets + +$$$websockets + +Cask's Websocket endpoints are very similar to Cask's HTTP endpoints. Annotated +with `@cask.websocket` instead of `@cask.get` or `@cast.post`, the primary +difference is that instead of only returning a `cask.Response`, you now have an +option of returning a `io.undertow.websockets.WebSocketConnectionCallback`. + +The `WebSocketConnectionCallback` allows you to pro-actively start sending +websocket messages once a connection has been made, and it lets you register a +`AbstractReceiveListener` that allows you to react to any messages the client on +the other side of the websocket connection sends you. You can use these two APIs +to perform full bi-directional, asynchronous communications, as websockets are +intended to be used for. + +Returning a `cask.Response` immediately closes the websocket connection, and is +useful if you want to e.g. return a 404 or 403 due to the initial request being +invalid. + ### TodoMVC Api Server diff --git a/example/endpoints/app/src/Endpoints.scala b/example/endpoints/app/src/Endpoints.scala index e2a14dc..9eadf39 100644 --- a/example/endpoints/app/src/Endpoints.scala +++ b/example/endpoints/app/src/Endpoints.scala @@ -1,7 +1,10 @@ package app +import cask.main.HttpDecorator +import cask.model.ParamContext -class custom(val path: String, val methods: Seq[String]) extends cask.Endpoint{ + +class custom(val path: String, val methods: Seq[String]) extends cask.Endpoint with HttpDecorator{ type Output = Int def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { delegate(Map()).map{num => diff --git a/example/websockets/app/src/Websockets.scala b/example/websockets/app/src/Websockets.scala new file mode 100644 index 0000000..277b06f --- /dev/null +++ b/example/websockets/app/src/Websockets.scala @@ -0,0 +1,29 @@ +package app + +import io.undertow.websockets.WebSocketConnectionCallback +import io.undertow.websockets.core.{AbstractReceiveListener, BufferedTextMessage, WebSocketChannel, WebSockets} +import io.undertow.websockets.spi.WebSocketHttpExchange + +object Websockets extends cask.MainRoutes{ + @cask.websocket("/connect/:userName") + def showUserProfile(userName: String): cask.WebsocketResult = { + if (userName != "haoyi") cask.Response("", statusCode = 403) + else new WebSocketConnectionCallback() { + override def onConnect(exchange: WebSocketHttpExchange, channel: WebSocketChannel): Unit = { + channel.getReceiveSetter.set( + new AbstractReceiveListener() { + override def onFullTextMessage(channel: WebSocketChannel, message: BufferedTextMessage) = { + val data = message.getData + if (data == "") channel.close() + else WebSockets.sendTextBlocking(userName + " " + data, channel) + } + } + ) + channel.resumeReceives() + + } + } + } + + initialize() +} diff --git a/example/websockets/app/test/src/ExampleTests.scala b/example/websockets/app/test/src/ExampleTests.scala new file mode 100644 index 0000000..01863e8 --- /dev/null +++ b/example/websockets/app/test/src/ExampleTests.scala @@ -0,0 +1,47 @@ +package app +import com.github.andyglow.websocket.WebsocketClient +import utest._ + +object ExampleTests extends TestSuite{ + def test[T](example: cask.main.BaseMain)(f: String => T): T = { + val server = io.undertow.Undertow.builder + .addHttpListener(8080, "localhost") + .setHandler(example.defaultHandler) + .build + server.start() + val res = + try f("http://localhost:8080") + finally server.stop() + res + } + + val tests = Tests{ + 'VariableRoutes - test(Websockets){ host => + @volatile var out = List.empty[String] + val cli = WebsocketClient[String]("ws://localhost:8080/connect/haoyi") { + case str => out = str :: out + } + + // 4. open websocket + val ws = cli.open() + + // 5. send messages + ws ! "hello" + ws ! "world" + ws ! "" + Thread.sleep(100) + out ==> List("haoyi world", "haoyi hello") + + val cli2 = WebsocketClient[String]("ws://localhost:8080/connect/nobody") { + case str => out = str :: out + } + + val error = + try cli2.open() + catch{case e: Throwable => e.getMessage} + + assert(error.toString.contains("Invalid handshake response getStatus: 403 Forbidden")) + } + + } +} diff --git a/example/websockets/build.sc b/example/websockets/build.sc new file mode 100644 index 0000000..bc8cf26 --- /dev/null +++ b/example/websockets/build.sc @@ -0,0 +1,19 @@ +import mill._, scalalib._ + + +trait AppModule extends ScalaModule{ + def scalaVersion = "2.12.6" + def ivyDeps = Agg( + ivy"com.lihaoyi::cask:0.0.1", + ) + + object test extends Tests{ + def testFrameworks = Seq("utest.runner.Framework") + + def ivyDeps = Agg( + ivy"com.lihaoyi::utest::0.6.3", + ivy"com.lihaoyi::requests::0.1.2", + ivy"com.github.andyglow::websocket-scala-client:0.2.4" + ) + } +}
\ No newline at end of file |