Skip to content

Commit

Permalink
WIP Play Client
Browse files Browse the repository at this point in the history
  • Loading branch information
gaeljw committed Oct 10, 2020
1 parent 567c05a commit e2403fa
Show file tree
Hide file tree
Showing 6 changed files with 315 additions and 0 deletions.
11 changes: 11 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,17 @@ lazy val sttpClient: ProjectMatrix = (projectMatrix in file("client/sttp-client"
.jvmPlatform(scalaVersions = allScalaVersions)
.dependsOn(core, clientTests % Test)

lazy val playClient: ProjectMatrix = (projectMatrix in file("client/play-client"))
.settings(commonSettings)
.settings(
name := "tapir-play-client",
libraryDependencies ++= Seq(
"com.typesafe.play" %% "play-ahc-ws-standalone" % Versions.playClient
)
)
.jvmPlatform(scalaVersions = allScalaVersions)
.dependsOn(core, clientTests % Test)

// other

lazy val examples: ProjectMatrix = (projectMatrix in file("examples"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package sttp.tapir.client.play

import java.io.{File, InputStream}
import java.nio.ByteBuffer
import java.util.function.Supplier

import akka.stream.scaladsl.Source
import akka.util.ByteString
import play.api.libs.ws.DefaultBodyWritables._
import play.api.libs.ws.{DefaultWSCookie, StandaloneWSClient, StandaloneWSRequest, StandaloneWSResponse}
import play.shaded.ahc.org.asynchttpclient.request.body.multipart.{ByteArrayPart, FilePart, InputStreamPart, StringPart}
import sttp.model.{HeaderNames, Method, Part}
import sttp.tapir.Codec.PlainCodec
import sttp.tapir.internal.{Params, ParamsAsAny, RichEndpointInput, SplitParams}
import sttp.tapir.{Codec, CodecFormat, DecodeResult, Endpoint, EndpointIO, EndpointInput, Mapping, RawBodyType, RawPart}

class EndpointToPlayClient() {

def toPlayRequest[I, E, O, S](e: Endpoint[I, E, O, S], baseUri: String)(implicit ws: StandaloneWSClient): I => StandaloneWSRequest = {
params =>
val req =
setInputParams(
e.input,
ParamsAsAny(params),
ws.url(baseUri)
)

req.withMethod(e.input.method.getOrElse(Method.GET).method)
}

def parsePlayResponse[I, E, O, S](e: Endpoint[I, E, O, S]): StandaloneWSResponse => DecodeResult[Either[E, O]] = { response =>
// TODO
DecodeResult.Value(Right(response.body.asInstanceOf[O]))
}

def parsePlayResponseUnsafe[I, E, O, S](e: Endpoint[I, E, O, S]): StandaloneWSResponse => Either[E, O] = { response =>
getOrThrow(parsePlayResponse(e).apply(response))
}

@scala.annotation.tailrec
private def setInputParams[I](
input: EndpointInput[I],
params: Params,
req: StandaloneWSRequest
): StandaloneWSRequest = {
def value: I = params.asAny.asInstanceOf[I]
input match {
case EndpointInput.FixedMethod(m, _, _) => req.withMethod(m.method)
case EndpointInput.FixedPath(p, _, _) =>
req.withUrl(req.url + "/" + p)
case EndpointInput.PathCapture(_, codec, _) =>
val v = codec.asInstanceOf[PlainCodec[Any]].encode(value: Any)
req.withUrl(req.url + "/" + v)
case EndpointInput.PathsCapture(codec, _) =>
val ps = codec.encode(value)
req.withUrl(req.url + ps.mkString("/", "/", ""))
case EndpointInput.Query(name, codec, _) =>
val req2 = codec.encode(value).foldLeft(req) { case (r, v) => r.addQueryStringParameters(name -> v) }
req2
case EndpointInput.Cookie(name, codec, _) =>
val req2 = codec.encode(value).foldLeft(req) { case (r, v) => r.addCookies(DefaultWSCookie(name, v)) }
req2
case EndpointInput.QueryParams(codec, _) =>
val mqp = codec.encode(value)
req.addQueryStringParameters(mqp.toSeq: _*)
case EndpointIO.Empty(_, _) => req
case EndpointIO.Body(bodyType, codec, _) =>
val req2 = setBody(value, bodyType, codec, req)
req2
case sbw @ EndpointIO.StreamBodyWrapper(_) =>
val req2 = setStreamingBody(value, sbw.codec, req)
req2
case EndpointIO.Header(name, codec, _) =>
val req2 = codec
.encode(value)
.foldLeft(req) { case (r, v) => r.addHttpHeaders(name -> v) }
req2
case EndpointIO.Headers(codec, _) =>
val headers = codec.encode(value)
val req2 = headers.foldLeft(req) { case (r, h) => r.addHttpHeaders(h.name -> h.value) }
req2
case EndpointIO.FixedHeader(h, _, _) =>
val req2 = req.addHttpHeaders(h.name -> h.value)
req2
case EndpointInput.ExtractFromRequest(_, _) =>
// ignoring
req
case a: EndpointInput.Auth[_] => setInputParams(a.input, params, req)
case EndpointInput.Pair(left, right, _, split) => handleInputPair(left, right, params, split, req)
case EndpointIO.Pair(left, right, _, split) => handleInputPair(left, right, params, split, req)
case EndpointInput.MappedPair(wrapped, codec) => handleMapped(wrapped, codec.asInstanceOf[Mapping[Any, Any]], params, req)
case EndpointIO.MappedPair(wrapped, codec) => handleMapped(wrapped, codec.asInstanceOf[Mapping[Any, Any]], params, req)
}
}

def handleInputPair(
left: EndpointInput[_],
right: EndpointInput[_],
params: Params,
split: SplitParams,
req: StandaloneWSRequest
): StandaloneWSRequest = {
val (leftParams, rightParams) = split(params)
val req2 = setInputParams(left.asInstanceOf[EndpointInput[Any]], leftParams, req)
setInputParams(right.asInstanceOf[EndpointInput[Any]], rightParams, req2)
}

private def handleMapped[II, T](
tuple: EndpointInput[II],
codec: Mapping[T, II],
params: Params,
req: StandaloneWSRequest
): StandaloneWSRequest = {
setInputParams(tuple.asInstanceOf[EndpointInput[Any]], ParamsAsAny(codec.encode(params.asAny.asInstanceOf[II])), req)
}

// TODO move elsewhere
type PlayPart = play.shaded.ahc.org.asynchttpclient.request.body.multipart.Part
type PlayPartBase = play.shaded.ahc.org.asynchttpclient.request.body.multipart.PartBase

private def setBody[R, T, CF <: CodecFormat](
v: T,
bodyType: RawBodyType[R],
codec: Codec[R, T, CF],
req: StandaloneWSRequest
): StandaloneWSRequest = {
val encoded: R = codec.encode(v)
// TODO can't we get rid of asInstanceOf ?
val req2 = bodyType match {
case RawBodyType.StringBody(_) => req.withBody(encoded.asInstanceOf[String]) // TODO: what about charset?
case RawBodyType.ByteArrayBody => req.withBody(encoded.asInstanceOf[Array[Byte]])
case RawBodyType.ByteBufferBody => req.withBody(encoded.asInstanceOf[ByteBuffer])
case RawBodyType.InputStreamBody =>
// For some reason, Play comes with a Writeable for Supplier[InputStream] but not InputStream directly
val inputStreamSupplier: Supplier[InputStream] = () => encoded.asInstanceOf[InputStream]
req.withBody(inputStreamSupplier)
case RawBodyType.FileBody => req.withBody(encoded.asInstanceOf[File])
case m: RawBodyType.MultipartBody =>
//
//import play.api.libs.ws.WSBodyReadables._
val parts: Seq[PlayPart] = (encoded: Seq[RawPart]).flatMap { p =>
m.partType(p.name).map { partType =>
// name, body, content type, content length, file name
val playPart =
partToPlayPart(p.asInstanceOf[Part[Any]], partType.asInstanceOf[RawBodyType[Any]], p.contentType, p.contentLength, p.fileName)
// headers; except content type set above
p.headers
.filterNot(_.is(HeaderNames.ContentType))
.foreach { header =>
playPart.addCustomHeader(header.name, header.value)
}
// TODO other disposition params, is there anything else than filename? filename is handled above
//p.otherDispositionParams.foreach{ case (k, v) => () }
}
}

// TODO we need a BodyWritable[Source[PlayPart, _]], maybe reuse a Writable from Play core??
// req.withBody(Source(parts.toList))
throw new RuntimeException("Not implemented yet")
}

// TODO
//req2.contentType(codec.format.mediaType)

req2
}

private def setStreamingBody[R, T, CF <: CodecFormat](v: T, codec: Codec[R, T, CF], req: StandaloneWSRequest): StandaloneWSRequest = {
val encoded: R = codec.encode(v)
encoded match {
case s: Source[ByteString, _] => req.withBody(s)
case is: InputStream =>
// For some reason, Play comes with a Writeable for Supplier[InputStream] but not InputStream directly
val inputStreamSupplier: Supplier[InputStream] = () => is
req.withBody(inputStreamSupplier)
case f: File => req.withBody(f)
case _ =>
// TODO what about other types that might have a BodyWritable defined out there?
throw new IllegalArgumentException("Streaming input other than Source, InputStream or File aren't supported")
}
}

private def partToPlayPart[R](
p: Part[R],
bodyType: RawBodyType[R],
contentType: Option[String],
contentLength: Option[Long],
fileName: Option[String]
): PlayPartBase = {
// TODO can't we get rid of the asInstanceOf???
bodyType match {
case RawBodyType.StringBody(charset) => new StringPart(p.name, p.body.asInstanceOf[String], contentType.orNull, charset)
case RawBodyType.ByteArrayBody => new ByteArrayPart(p.name, p.body.asInstanceOf[Array[Byte]], contentType.orNull)
case RawBodyType.ByteBufferBody => new ByteArrayPart(p.name, p.body.asInstanceOf[ByteBuffer].array(), contentType.orNull)
case RawBodyType.InputStreamBody =>
new InputStreamPart(p.name, p.body.asInstanceOf[InputStream], fileName.orNull, contentLength.getOrElse(-1L), contentType.orNull)
case RawBodyType.FileBody => new FilePart(p.name, p.body.asInstanceOf[File], contentType.orNull)
case RawBodyType.MultipartBody(_, _) => throw new IllegalArgumentException("Nested multipart bodies aren't supported")
}
}

private def getOrThrow[T](dr: DecodeResult[T]): T =
dr match {
case DecodeResult.Value(v) => v
case DecodeResult.Error(_, e) => throw e
case f => throw new IllegalArgumentException(s"Cannot decode: $f")
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package sttp.tapir.client.play

import play.api.libs.ws.{StandaloneWSClient, StandaloneWSRequest, StandaloneWSResponse}
import sttp.tapir.{DecodeResult, Endpoint}

trait TapirPlayClient {

implicit class RichPlayClientEndpoint[I, E, O, S](e: Endpoint[I, E, O, S]) {

/**
* Interprets the endpoint as a client call, using the given `baseUri` as the starting point to create the target
* uri.
*
* Returns a function which, when applied to the endpoint's input parameters (given as a tuple), will encode them
* to appropriate request parameters: path, query, headers and body. The result is a `StandaloneWSRequest`,
* which can be sent using the `execute()` method.
*/
def toPlayRequest(baseUri: String)(implicit ws: StandaloneWSClient): I => StandaloneWSRequest =
new EndpointToPlayClient().toPlayRequest(e, baseUri)

/**
* Uses the endpoint definition to parse a response.
*/
def parsePlayResponseUnsafe: StandaloneWSResponse => Either[E, O] =
new EndpointToPlayClient().parsePlayResponseUnsafe(e)

/**
* Uses the endpoint definition to parse a response.
*/
def parsePlayResponse: StandaloneWSResponse => DecodeResult[Either[E, O]] =
new EndpointToPlayClient().parsePlayResponse(e)

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package sttp.tapir.client

package object play extends TapirPlayClient
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package sttp.tapir.client.play

import java.nio.charset.StandardCharsets

import akka.actor.ActorSystem
import akka.stream.Materializer
import akka.stream.scaladsl.Source
import akka.util.ByteString
import cats.effect.{ContextShift, IO}
import play.api.libs.ws.StandaloneWSClient
import play.api.libs.ws.ahc.StandaloneAhcWSClient
import sttp.tapir.client.tests.ClientTests
import sttp.tapir.{DecodeResult, Endpoint}

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.DurationInt
import scala.concurrent.{Await, Future}

class PlayClientTests extends ClientTests[Source[ByteString, _]] {

implicit val materializer: Materializer = Materializer(ActorSystem("tests"))

implicit val wsClient: StandaloneWSClient = StandaloneAhcWSClient()

implicit val cs: ContextShift[IO] = cats.effect.IO.contextShift(global)

override def mkStream(s: String): Source[ByteString, _] = Source(List(ByteString.fromString(s)))

override def rmStream(s: Source[ByteString, _]): String = {
val f = s.runFold("")((str, bs) => str + bs.decodeString(StandardCharsets.UTF_8))
Await.result(f, 10.seconds)
}

override def send[I, E, O, FN[_]](e: Endpoint[I, E, O, Source[ByteString, _]], port: Port, args: I): IO[Either[E, O]] = {
def response: Future[Either[E, O]] =
e.toPlayRequest("http://localhost:$port")
.apply(args)
.execute()
.map(e.parsePlayResponseUnsafe)
IO.fromFuture(IO(response))
}

override def safeSend[I, E, O, FN[_]](
e: Endpoint[I, E, O, Source[ByteString, _]],
port: Port,
args: I
): IO[DecodeResult[Either[E, O]]] = {
def response: Future[DecodeResult[Either[E, O]]] =
e.toPlayRequest("http://localhost:$port")
.apply(args)
.execute()
.map(e.parsePlayResponse)
IO.fromFuture(IO(response))
}

}
1 change: 1 addition & 0 deletions project/Versions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ object Versions {
val enumeratum = "1.6.1"
val zio = "1.0.1"
val zioInteropCats = "2.1.4.0"
val playClient = "2.1.2"
val playServer = "2.8.2"
val tethys = "0.11.0"
val vertx = "3.9.1"
Expand Down

0 comments on commit e2403fa

Please sign in to comment.