diff --git a/build.sbt b/build.sbt index 12e5e86ea..c16c00a56 100644 --- a/build.sbt +++ b/build.sbt @@ -130,7 +130,7 @@ lazy val udash = project.in(file(".")) utils, `utils-js`, core, `core-js`, rpc, `rpc-js`, - rest, `rest-js`, + rest, `rest-js`, `rest-jetty`, i18n, `i18n-js`, auth, `auth-js`, css, `css-js`, @@ -196,6 +196,12 @@ lazy val `rest-js` = jsProjectFor(project, rest) libraryDependencies ++= Dependencies.restSjsDeps.value, ) +lazy val `rest-jetty` = jvmProject(project.in(file("rest/jetty"))) + .dependsOn(rest % CompileAndTest) + .settings( + libraryDependencies ++= Dependencies.restJettyDeps.value, + ) + lazy val i18n = jvmProject(project) .dependsOn(core % CompileAndTest, rpc % CompileAndTest) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a9e27be60..f0bf3bdcc 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -115,6 +115,10 @@ object Dependencies { )) val restSjsDeps = restCrossDeps + + val restJettyDeps = Def.setting(Seq( + "org.eclipse.jetty" % "jetty-client" % jettyVersion + )) private val cssCrossDeps = Def.setting(Seq( "com.github.japgolly.scalacss" %%% "core" % scalaCssVersion, diff --git a/rest/.jvm/src/main/scala/io/udash/rest/RestServlet.scala b/rest/.jvm/src/main/scala/io/udash/rest/RestServlet.scala index 8cb40df99..e679e1ba8 100644 --- a/rest/.jvm/src/main/scala/io/udash/rest/RestServlet.scala +++ b/rest/.jvm/src/main/scala/io/udash/rest/RestServlet.scala @@ -9,6 +9,7 @@ import com.typesafe.scalalogging.LazyLogging import io.udash.rest.RestServlet._ import io.udash.rest.raw._ import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse} +import javax.servlet.{AsyncEvent, AsyncListener} import scala.annotation.tailrec import scala.concurrent.duration._ @@ -44,7 +45,17 @@ class RestServlet( import RestServlet._ override def service(request: HttpServletRequest, response: HttpServletResponse): Unit = { - val asyncContext = request.startAsync().setup(_.setTimeout(handleTimeout.toMillis)) + val asyncContext = request.startAsync() + asyncContext.setTimeout(handleTimeout.toMillis) + asyncContext.addListener(new AsyncListener { + def onComplete(event: AsyncEvent): Unit = () + def onTimeout(event: AsyncEvent): Unit = { + writeFailure(response, Opt("server operation timed out")) + asyncContext.complete() + } + def onError(event: AsyncEvent): Unit = () + def onStartAsync(event: AsyncEvent): Unit = () + }) RawRest.safeAsync(handleRequest(readRequest(request))) { case Success(restResponse) => writeResponse(response, restResponse) diff --git a/rest/.jvm/src/test/resources/RestTestApi.json b/rest/.jvm/src/test/resources/RestTestApi.json index 206db8e58..db5309fa2 100644 --- a/rest/.jvm/src/test/resources/RestTestApi.json +++ b/rest/.jvm/src/test/resources/RestTestApi.json @@ -463,6 +463,16 @@ } } }, + "/neverGet": { + "get": { + "operationId": "neverGet", + "responses": { + "204": { + "description": "Success" + } + } + } + }, "/prefix/{p0}/subget/{p1}": { "summary": "summary for prefix paths", "get": { diff --git a/rest/.jvm/src/test/scala/io/udash/rest/HttpRestCallTest.scala b/rest/.jvm/src/test/scala/io/udash/rest/HttpRestCallTest.scala deleted file mode 100644 index a9da9c808..000000000 --- a/rest/.jvm/src/test/scala/io/udash/rest/HttpRestCallTest.scala +++ /dev/null @@ -1,36 +0,0 @@ -package io.udash -package rest - -import com.softwaremill.sttp.SttpBackend -import io.udash.rest.raw.HttpErrorException -import io.udash.rest.raw.RawRest.HandleRequest -import org.eclipse.jetty.server.Server -import org.eclipse.jetty.servlet.{ServletHandler, ServletHolder} - -import scala.concurrent.Future -import scala.concurrent.duration._ - -class HttpRestCallTest extends AbstractRestCallTest with UsesHttpServer { - override implicit def patienceConfig: PatienceConfig = PatienceConfig(10.seconds) - - implicit val backend: SttpBackend[Future, Nothing] = SttpRestClient.defaultBackend() - - final val MaxPayloadSize = 1024 * 1024 - - protected def setupServer(server: Server): Unit = { - val servlet = new RestServlet(serverHandle, maxPayloadSize = MaxPayloadSize) - val holder = new ServletHolder(servlet) - val handler = new ServletHandler - handler.addServletWithMapping(holder, "/api/*") - server.setHandler(handler) - } - - def clientHandle: HandleRequest = - SttpRestClient.asHandleRequest(s"$baseUrl/api") - - test("too large binary request") { - val future = proxy.binaryEcho(Array.fill[Byte](MaxPayloadSize + 1)(5)) - val exception = future.failed.futureValue - assert(exception == HttpErrorException(413, "Payload is larger than maximum 1048576 bytes (1048577)")) - } -} diff --git a/rest/.jvm/src/test/scala/io/udash/rest/ServletBasedRestApiTest.scala b/rest/.jvm/src/test/scala/io/udash/rest/ServletBasedRestApiTest.scala new file mode 100644 index 000000000..e81a2fcd6 --- /dev/null +++ b/rest/.jvm/src/test/scala/io/udash/rest/ServletBasedRestApiTest.scala @@ -0,0 +1,22 @@ +package io.udash +package rest + +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.{ServletHandler, ServletHolder} + +import scala.concurrent.duration._ + +abstract class ServletBasedRestApiTest extends RestApiTest with UsesHttpServer { + override implicit def patienceConfig: PatienceConfig = PatienceConfig(10.seconds) + + def maxPayloadSize: Int = 1024 * 1024 + def serverTimeout: FiniteDuration = 10.seconds + + protected def setupServer(server: Server): Unit = { + val servlet = new RestServlet(serverHandle, serverTimeout, maxPayloadSize) + val holder = new ServletHolder(servlet) + val handler = new ServletHandler + handler.addServletWithMapping(holder, "/api/*") + server.setHandler(handler) + } +} diff --git a/rest/.jvm/src/test/scala/io/udash/rest/SttpRestCallTest.scala b/rest/.jvm/src/test/scala/io/udash/rest/SttpRestCallTest.scala new file mode 100644 index 000000000..b3c1a36de --- /dev/null +++ b/rest/.jvm/src/test/scala/io/udash/rest/SttpRestCallTest.scala @@ -0,0 +1,41 @@ +package io.udash +package rest + +import com.softwaremill.sttp.SttpBackend +import io.udash.rest.raw.HttpErrorException +import io.udash.rest.raw.RawRest.HandleRequest + +import scala.concurrent.Future +import scala.concurrent.duration._ + +trait SttpClientRestTest extends ServletBasedRestApiTest { + implicit val backend: SttpBackend[Future, Nothing] = SttpRestClient.defaultBackend() + + def clientHandle: HandleRequest = + SttpRestClient.asHandleRequest(s"$baseUrl/api") + + override protected def afterAll(): Unit = { + backend.close() + super.afterAll() + } +} + +class SttpRestCallTest extends SttpClientRestTest with RestApiTestScenarios { + def port: Int = 9090 + + test("too large binary request") { + val future = proxy.binaryEcho(Array.fill[Byte](maxPayloadSize + 1)(5)) + val exception = future.failed.futureValue + assert(exception == HttpErrorException(413, "Payload is larger than maximum 1048576 bytes (1048577)")) + } +} + +class ServletTimeoutTest extends SttpClientRestTest { + def port: Int = 9091 + override def serverTimeout: FiniteDuration = 1.millisecond + + test("rest method timeout") { + val exception = proxy.neverGet.failed.futureValue + assert(exception == HttpErrorException(500, "server operation timed out")) + } +} diff --git a/rest/.jvm/src/test/scala/io/udash/rest/UsesHttpServer.scala b/rest/.jvm/src/test/scala/io/udash/rest/UsesHttpServer.scala index dac1a98d9..1ef15065f 100644 --- a/rest/.jvm/src/test/scala/io/udash/rest/UsesHttpServer.scala +++ b/rest/.jvm/src/test/scala/io/udash/rest/UsesHttpServer.scala @@ -5,9 +5,9 @@ import org.eclipse.jetty.server.Server import org.scalatest.{BeforeAndAfterAll, Suite} trait UsesHttpServer extends BeforeAndAfterAll { this: Suite => - val port: Int = 9090 + def port: Int val server: Server = new Server(port) - val baseUrl = s"http://localhost:$port" + def baseUrl = s"http://localhost:$port" protected def setupServer(server: Server): Unit diff --git a/rest/jetty/src/main/scala/io/udash/rest/jetty/JettyRestClient.scala b/rest/jetty/src/main/scala/io/udash/rest/jetty/JettyRestClient.scala new file mode 100644 index 000000000..889f68c90 --- /dev/null +++ b/rest/jetty/src/main/scala/io/udash/rest/jetty/JettyRestClient.scala @@ -0,0 +1,87 @@ +package io.udash +package rest.jetty + +import java.net.HttpCookie +import java.nio.charset.Charset + +import com.avsystem.commons._ +import com.avsystem.commons.annotation.explicitGenerics +import io.udash.rest.raw._ +import org.eclipse.jetty.client.HttpClient +import org.eclipse.jetty.client.api.Result +import org.eclipse.jetty.client.util.{BufferingResponseListener, BytesContentProvider, StringContentProvider} +import org.eclipse.jetty.http.{HttpHeader, MimeTypes} + +import scala.util.{Failure, Success} +import scala.concurrent.duration._ + +object JettyRestClient { + final val DefaultMaxResponseLength = 2 * 1024 * 1024 + final val DefaultTimeout = 10.seconds + + @explicitGenerics def apply[RestApi: RawRest.AsRealRpc : RestMetadata]( + client: HttpClient, + baseUri: String, + maxResponseLength: Int = DefaultMaxResponseLength, + timeout: Duration = DefaultTimeout + ): RestApi = + RawRest.fromHandleRequest[RestApi](asHandleRequest(client, baseUri, maxResponseLength, timeout)) + + def asHandleRequest( + client: HttpClient, + baseUrl: String, + maxResponseLength: Int = DefaultMaxResponseLength, + timeout: Duration = DefaultTimeout + ): RawRest.HandleRequest = + RawRest.safeHandle { request => + callback => + val path = baseUrl + PlainValue.encodePath(request.parameters.path) + val httpReq = client.newRequest(baseUrl).method(request.method.name) + + httpReq.path(path) + request.parameters.query.entries.foreach { + case (name, PlainValue(value)) => httpReq.param(name, value) + } + request.parameters.headers.entries.foreach { + case (name, PlainValue(value)) => httpReq.header(name, value) + } + request.parameters.cookies.entries.foreach { + case (name, PlainValue(value)) => httpReq.cookie(new HttpCookie(name, value)) + } + + request.body match { + case HttpBody.Empty => + case tb: HttpBody.Textual => + httpReq.content(new StringContentProvider(tb.contentType, tb.content, Charset.forName(tb.charset))) + case bb: HttpBody.Binary => + httpReq.content(new BytesContentProvider(bb.contentType, bb.bytes)) + } + + timeout match { + case fd: FiniteDuration => httpReq.timeout(fd.length, fd.unit) + case _ => + } + + httpReq.send(new BufferingResponseListener(maxResponseLength) { + override def onComplete(result: Result): Unit = + if (result.isSucceeded) { + val httpResp = result.getResponse + val contentTypeOpt = httpResp.getHeaders.get(HttpHeader.CONTENT_TYPE).opt + val charsetOpt = contentTypeOpt.map(MimeTypes.getCharsetFromContentType) + val body = (contentTypeOpt, charsetOpt) match { + case (Opt(contentType), Opt(charset)) => + HttpBody.textual(getContentAsString, MimeTypes.getContentTypeWithoutCharset(contentType), charset) + case (Opt(contentType), Opt.Empty) => + HttpBody.binary(getContent, contentType) + case _ => + HttpBody.Empty + } + val headers = httpResp.getHeaders.iterator.asScala.map(h => (h.getName, PlainValue(h.getValue))).toList + val response = RestResponse(httpResp.getStatus, IMapping(headers), body) + callback(Success(response)) + } else { + callback(Failure(result.getFailure)) + } + }) + } +} diff --git a/rest/jetty/src/test/scala/io/udash/rest/jetty/JettyRestCallTest.scala b/rest/jetty/src/test/scala/io/udash/rest/jetty/JettyRestCallTest.scala new file mode 100644 index 000000000..8013c9a20 --- /dev/null +++ b/rest/jetty/src/test/scala/io/udash/rest/jetty/JettyRestCallTest.scala @@ -0,0 +1,24 @@ +package io.udash +package rest.jetty + +import io.udash.rest.raw.RawRest.HandleRequest +import io.udash.rest.{RestApiTestScenarios, ServletBasedRestApiTest} +import org.eclipse.jetty.client.HttpClient + +final class JettyRestCallTest extends ServletBasedRestApiTest with RestApiTestScenarios { + def port: Int = 9092 + val client: HttpClient = new HttpClient + + def clientHandle: HandleRequest = + JettyRestClient.asHandleRequest(client, s"$baseUrl/api", maxPayloadSize) + + override protected def beforeAll(): Unit = { + super.beforeAll() + client.start() + } + + override protected def afterAll(): Unit = { + client.stop() + super.afterAll() + } +} diff --git a/rest/src/test/scala/io/udash/rest/AbstractRestCallTest.scala b/rest/src/test/scala/io/udash/rest/RestApiTest.scala similarity index 91% rename from rest/src/test/scala/io/udash/rest/AbstractRestCallTest.scala rename to rest/src/test/scala/io/udash/rest/RestApiTest.scala index 0b9eb84f8..ed1de947b 100644 --- a/rest/src/test/scala/io/udash/rest/AbstractRestCallTest.scala +++ b/rest/src/test/scala/io/udash/rest/RestApiTest.scala @@ -8,7 +8,7 @@ import org.scalactic.source.Position import org.scalatest.FunSuite import org.scalatest.concurrent.ScalaFutures -abstract class AbstractRestCallTest extends FunSuite with ScalaFutures { +abstract class RestApiTest extends FunSuite with ScalaFutures { final val serverHandle: RawRest.HandleRequest = RawRest.asHandleRequest[RestTestApi](RestTestApi.Impl) @@ -27,7 +27,9 @@ abstract class AbstractRestCallTest extends FunSuite with ScalaFutures { case arr: Array[_] => arr.deep case _ => value } +} +trait RestApiTestScenarios extends RestApiTest { test("trivial GET") { testCall(_.trivialGet) } @@ -73,6 +75,6 @@ abstract class AbstractRestCallTest extends FunSuite with ScalaFutures { } } -class DirectRestCallTest extends AbstractRestCallTest { +class DirectRestApiTest extends RestApiTestScenarios { def clientHandle: HandleRequest = serverHandle } diff --git a/rest/src/test/scala/io/udash/rest/RestTestApi.scala b/rest/src/test/scala/io/udash/rest/RestTestApi.scala index 67d66b5d8..2ac0ca7f9 100644 --- a/rest/src/test/scala/io/udash/rest/RestTestApi.scala +++ b/rest/src/test/scala/io/udash/rest/RestTestApi.scala @@ -59,6 +59,7 @@ trait RestTestApi { @GET def trivialGet: Future[Unit] @GET def failingGet: Future[Unit] @GET def moreFailingGet: Future[Unit] + @GET def neverGet: Future[Unit] @GET def getEntity(id: RestEntityId): Future[RestEntity] @@ -122,6 +123,7 @@ object RestTestApi extends DefaultRestApiCompanion[RestTestApi] { def trivialGet: Future[Unit] = Future.unit def failingGet: Future[Unit] = Future.failed(HttpErrorException(503, "nie")) def moreFailingGet: Future[Unit] = throw HttpErrorException(503, "nie") + def neverGet: Future[Unit] = Promise[Unit].future // Future.never if it wasn't for Scala 2.11 def getEntity(id: RestEntityId): Future[RestEntity] = Future.successful(RestEntity(id, s"${id.value}-name")) def complexGet(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, c1: Int, c2: String): Future[RestEntity] = Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$c1"), s"$p2-$h2-$q2-$c2"))