diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ac2caf6..34ba117a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,6 @@ -name: Scala Build CI -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop +name: Scala Build and Test CI +on: push + jobs: build: runs-on: ubuntu-latest @@ -15,6 +8,12 @@ jobs: - uses: actions/checkout@v2 - name: run compile run: sbt compile + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: run tests + run: sbt test lint: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0a41d54..3c501f4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - name: publish to new Tag - run: sbt publish + run: sbt publishSmithy4Play env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.event.release.tag_name }} diff --git a/build.sbt b/build.sbt index 2b5b3c1d..335cec73 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,10 @@ import sbt.Compile +import sbt.Keys.cleanFiles -val releaseVersion = sys.env.getOrElse("TAG", "0.2.2-1") - +val releaseVersion = sys.env.getOrElse("TAG", "0.2.3-BETA") +addCommandAlias("publishSmithy4Play", "smithy4play/publish") +addCommandAlias("publishLocalSmithy4Play", "smithy4play/publishLocal") +addCommandAlias("generateCoverage", "clean; coverage; test; coverageReport") val token = sys.env.getOrElse("GITHUB_TOKEN", "") val githubSettings = Seq( githubOwner := "innFactory", @@ -30,9 +33,7 @@ val sharedSettings = defaultProjectSettings lazy val smithy4play = project .in(file("smithy4play")) .settings( - sharedSettings - ) - .settings( + sharedSettings, scalaVersion := Dependencies.scalaVersion, name := "smithy4play", scalacOptions += "-Ymacro-annotations", @@ -40,4 +41,26 @@ lazy val smithy4play = project libraryDependencies ++= Dependencies.list ) -lazy val root = project.in(file(".")).settings(sharedSettings).dependsOn(smithy4play).aggregate(smithy4play) +lazy val smithy4playTest = project + .in(file("smithy4playTest")) + .enablePlugins(Smithy4sCodegenPlugin, PlayScala) + .settings( + sharedSettings, + scalaVersion := Dependencies.scalaVersion, + name := "smithy4playTest", + scalacOptions += "-Ymacro-annotations", + Compile / compile / wartremoverWarnings ++= Warts.unsafe, + cleanKeepFiles += (ThisBuild / baseDirectory).value / "smithy4playTest" / "app", + cleanFiles += (ThisBuild / baseDirectory).value / "smithy4playTest" / "app" / "testDefinitions" / "test", + Compile / smithy4sInputDir := (ThisBuild / baseDirectory).value / "smithy4playTest" / "testSpecs", + Compile / smithy4sOutputDir := (ThisBuild / baseDirectory).value / "smithy4playTest" / "app", + libraryDependencies ++= Seq( + guice, + Dependencies.cats, + Dependencies.smithyCore, + Dependencies.scalatestPlus + ) + ) + .dependsOn(smithy4play) + +lazy val root = project.in(file(".")).settings(sharedSettings).aggregate(smithy4play, smithy4playTest) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 751edc39..97afe18e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -8,13 +8,13 @@ object Dependencies { val scalaVersion = "2.13.8" - val smithyCore = "com.disneystreaming.smithy4s" %% "smithy4s-core" % "0.14.2" - val smithyJson = "com.disneystreaming.smithy4s" %% "smithy4s-json" % "0.14.2" + val smithyCore = "com.disneystreaming.smithy4s" %% "smithy4s-core" % "0.15.2" + val smithyJson = "com.disneystreaming.smithy4s" %% "smithy4s-json" % "0.15.2" val classgraph = "io.github.classgraph" % "classgraph" % "4.8.149" val scalatestPlus = "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test - val cats = "org.typelevel" %% "cats-core" % "2.7.0" + val cats = "org.typelevel" %% "cats-core" % "2.8.0" lazy val list = Seq( smithyCore, diff --git a/project/plugins.sbt b/project/plugins.sbt index bd085c68..19ab39c2 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,3 +1,6 @@ -addSbtPlugin("com.codecommit" % "sbt-github-packages" % "0.5.3") -addSbtPlugin("org.wartremover" % "sbt-wartremover" % "3.0.5") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") +addSbtPlugin("com.codecommit" % "sbt-github-packages" % "0.5.3") +addSbtPlugin("org.wartremover" % "sbt-wartremover" % "3.0.5") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.15.2") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.15") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3") diff --git a/readme.md b/readme.md index dbce6f86..cc56ab0a 100644 --- a/readme.md +++ b/readme.md @@ -63,6 +63,10 @@ Autorouting ```scala -> / de.innfactory.smithy4play.AutoRouter ``` +- add package name to configuration +```scala +smithy4play.autoRoutePackage = "your.package.name" +``` Selfbinding diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala index c83d8bb6..8709d4c4 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala @@ -18,6 +18,7 @@ trait AutoRoutableController { cc: ControllerComponents ): Routes = new SmithyPlayRouter[Alg, Op, F](impl).routes() + val routes: Routes } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala index 205aa3fc..69ce2e65 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala @@ -23,7 +23,7 @@ class AutoRouter @Inject( val pkg = config.getString("smithy4play.autoRoutePackage") val classGraphScanner: ScanResult = new ClassGraph().enableAllInfo().acceptPackages(pkg).scan() val controllers = classGraphScanner.getClassesImplementing(classOf[AutoRoutableController]) - logger.debug(s"[AutoRouter] found ${controllers.size()} Controllers") + logger.debug(s"[AutoRouter] found ${controllers.size().toString} Controllers") val routes = controllers.asScala.map(_.loadClass(true)).map(clazz => createFromClass(clazz)).toSeq classGraphScanner.close() routes diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala index 6b7f1fd9..dd267628 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala @@ -1,5 +1,6 @@ package de.innfactory.smithy4play +import akka.util.ByteString import cats.data.EitherT import play.api.mvc.{ AbstractController, @@ -32,15 +33,15 @@ class SmithyPlayEndpoint[F[_] <: ContextRoute[_], Op[ )(implicit cc: ControllerComponents, ec: ExecutionContext) extends AbstractController(cc) { - private val httpEndpoint = HttpEndpoint.cast(endpoint) + private val httpEndpoint: Option[HttpEndpoint[I]] = HttpEndpoint.cast(endpoint) private val inputSchema: Schema[I] = endpoint.input private val outputSchema: Schema[O] = endpoint.output - private val inputMetadataDecoder = + private val inputMetadataDecoder: Metadata.PartialDecoder[I] = Metadata.PartialDecoder.fromSchema(inputSchema) - private val outputMetadataEncoder = + private val outputMetadataEncoder: Metadata.Encoder[O] = Metadata.Encoder.fromSchema(outputSchema) def handler(v1: RequestHeader): Handler = @@ -67,13 +68,6 @@ class SmithyPlayEndpoint[F[_] <: ContextRoute[_], Op[ } .getOrElse(Action(NotFound("404"))) - def handleFailure(error: ContextRouteError): Result = - Results.Status(error.statusCode)( - Json.toJson( - RoutingErrorResponse(error.message, error.additionalInfoErrorCode) - ) - ) - private def getPathParams( v1: RequestHeader, httpEp: HttpEndpoint[I] @@ -107,7 +101,8 @@ class SmithyPlayEndpoint[F[_] <: ContextRoute[_], Op[ ) } case None => - request.contentType.get match { + println(request.contentType.getOrElse("application/json")) + request.contentType.getOrElse("application/json") match { case "application/json" => parseJson(request, metadata) case _ => parseRaw(request, metadata) } @@ -115,7 +110,8 @@ class SmithyPlayEndpoint[F[_] <: ContextRoute[_], Op[ }) ) - private def parseJson(request: Request[RawBuffer], metadata: Metadata) = + private def parseJson(request: Request[RawBuffer], metadata: Metadata): Either[ContextRouteError, I] = { + val codec = codecs.compileCodec(inputSchema) for { metadataPartial <- inputMetadataDecoder .decode(metadata) @@ -126,18 +122,18 @@ class SmithyPlayEndpoint[F[_] <: ContextRoute[_], Op[ 500 ) } - codec = codecs.compileCodec(inputSchema) c <- codecs .decodeFromByteBufferPartial( codec, - request.body.asBytes().get.toByteBuffer + request.body.asBytes().getOrElse(ByteString.empty).toByteBuffer ) .leftMap(e => Smithy4PlayError(s"expected: ${e.expected}", 400)) } yield metadataPartial.combine(c) + } - private def parseRaw(request: Request[RawBuffer], metadata: Metadata) = { + private def parseRaw(request: Request[RawBuffer], metadata: Metadata): Either[ContextRouteError, I] = { val nativeCodec: CodecAPI = CodecAPI.nativeStringsAndBlob(codecs) - val input = ByteArray(request.body.asBytes().get.toArray) + val input = ByteArray(request.body.asBytes().getOrElse(ByteString.empty).toArray) val codec = nativeCodec .compileCodec(inputSchema) for { @@ -156,14 +152,21 @@ class SmithyPlayEndpoint[F[_] <: ContextRoute[_], Op[ } yield metadataPartial.combine(bodyPartial) } - private def getMetadata(pathParams: PathParams, request: RequestHeader) = + private def getMetadata(pathParams: PathParams, request: RequestHeader): Metadata = Metadata( path = pathParams, - headers = getHeaders(request), + headers = getHeaders(request.headers), query = request.queryString.map { case (k, v) => (k.trim, v) } ) - private def handleSuccess(output: O, code: Int) = { + def handleFailure(error: ContextRouteError): Result = + Results.Status(error.statusCode)( + Json.toJson( + RoutingErrorResponse(error.message, error.additionalInfoErrorCode) + ) + ) + + private def handleSuccess(output: O, code: Int): Result = { val outputMetadata = outputMetadataEncoder.encode(output) val outputHeaders = outputMetadata.headers.map { case (k, v) => (k.toString, v.mkString("")) diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala index 84777d46..4655b9e9 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala @@ -1,8 +1,9 @@ package de.innfactory.smithy4play -import play.api.mvc.{ ControllerComponents, Handler, RequestHeader } +import cats.implicits.toTraverseOps +import play.api.mvc.{ AbstractController, ControllerComponents, Handler, RequestHeader } import play.api.routing.Router.Routes -import smithy4s.http.{ matchPath, HttpEndpoint, HttpMethod, PathSegment } +import smithy4s.http.{ HttpEndpoint, PathSegment } import smithy4s.{ Endpoint, GenLift, HintMask, Monadic, Service, Transformation } import smithy4s.internals.InputOutput @@ -12,7 +13,8 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], Op[_, _, _, _, _], F[ _ ] <: ContextRoute[_]]( impl: Monadic[Alg, F] -)(implicit cc: ControllerComponents, ec: ExecutionContext) { +)(implicit cc: ControllerComponents, ec: ExecutionContext) + extends AbstractController(cc) { def routes()(implicit serviceProvider: smithy4s.Service.Provider[Alg, Op] @@ -21,32 +23,27 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], Op[_, _, _, _, _], F[ val service: Service[Alg, Op] = serviceProvider.service val interpreter: Transformation[Op, GenLift[F]#λ] = service.asTransformation[GenLift[F]#λ](impl) val endpoints: Seq[Endpoint[Op, _, _, _, _, _]] = service.endpoints - val httpEndpoints = endpoints.map(HttpEndpoint.cast(_).get) + val httpEndpoints: Seq[Option[HttpEndpoint[_]]] = endpoints.map(HttpEndpoint.cast(_)) new PartialFunction[RequestHeader, Handler] { override def isDefinedAt(x: RequestHeader): Boolean = { - logger.debug("[SmithyPlayRouter] calling isDefinedAt on service: " + service.id.name + "for path: " + x.path) - httpEndpoints.exists(ep => checkIfRequestHeaderMatchesEndpoint(x, ep)) + logger.debug("[SmithyPlayRouter] calling isDefinedAt on service: " + service.id.name + " for path: " + x.path) + httpEndpoints.exists(ep => ep.exists(checkIfRequestHeaderMatchesEndpoint(x, _))) } override def apply(v1: RequestHeader): Handler = { logger.debug("[SmithyPlayRouter] calling apply on: " + service.id.name) - - val validEndpoint = endpoints.find(endpoint => - checkIfRequestHeaderMatchesEndpoint( - v1, - HttpEndpoint.cast(endpoint).get - ) - ) - new SmithyPlayEndpoint( + for { + zippedEndpoints <- endpoints.map(ep => HttpEndpoint.cast(ep).map((ep, _))).sequence + endpointAndHttpEndpoint <- zippedEndpoints.find(ep => checkIfRequestHeaderMatchesEndpoint(v1, ep._2)) + } yield new SmithyPlayEndpoint( interpreter, - validEndpoint.get, - smithy4s.http.json.codecs( - smithy4s.api.SimpleRestJson.protocol.hintMask ++ HintMask( - InputOutput - ) - ) + endpointAndHttpEndpoint._1, + smithy4s.http.json.codecs(smithy4s.api.SimpleRestJson.protocol.hintMask ++ HintMask(InputOutput)) ).handler(v1) + } match { + case Some(value) => value + case None => throw new Exception("Could not cast Endpoint to HttpEndpoint, likely a bug in smithy4s") } } @@ -56,20 +53,13 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], Op[_, _, _, _, _], F[ x: RequestHeader, ep: HttpEndpoint[_] ) = { - ep.path.foreach { - case PathSegment.StaticSegment(value) => - if (value.contains(" ")) - logger.info("following pathSegment contains a space: " + value) - case PathSegment.LabelSegment(value) => - if (value.contains(" ")) - logger.info("following pathSegment contains a space: " + value) - case PathSegment.GreedySegment(value) => - if (value.contains(" ")) - logger.info("following pathSegment contains a space: " + value) + ep.path.map { + case PathSegment.StaticSegment(value) => value + case PathSegment.LabelSegment(value) => value + case PathSegment.GreedySegment(value) => value } - matchRequestPath(x, ep).isDefined && x.method - .equals( - ep.method.showUppercase - ) + .filter(_.contains(" ")) + .foreach(value => logger.info("following pathSegment contains a space: " + value)) + matchRequestPath(x, ep).isDefined && x.method == ep.method.showUppercase } } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala new file mode 100644 index 00000000..569848ff --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala @@ -0,0 +1,16 @@ +package de.innfactory.smithy4play.client + +import play.api.mvc.Headers + +import scala.concurrent.Future + +case class SmithyClientResponse(body: Option[Array[Byte]], headers: Map[String, Seq[String]], statusCode: Int) + +trait RequestClient { + def send( + method: String, + path: String, + headers: Map[String, Seq[String]], + body: Option[Array[Byte]] + ): Future[SmithyClientResponse] +} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala new file mode 100644 index 00000000..920603db --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala @@ -0,0 +1,27 @@ +package de.innfactory.smithy4play.client + +import de.innfactory.smithy4play.ClientResponse +import smithy4s.http.HttpEndpoint + +import scala.concurrent.ExecutionContext + +class SmithyPlayClient[Alg[_[_, _, _, _, _]], Op[_, _, _, _, _], F[_]]( + baseUri: String, + service: smithy4s.Service[Alg, Op] +)(implicit executionContext: ExecutionContext, client: RequestClient) { + + def send[I, E, O, SI, SO]( + op: Op[I, E, O, SI, SO], + additionalHeaders: Option[Map[String, Seq[String]]] + ): ClientResponse[O] = { + + val (input, endpoint) = service.endpoint(op) + HttpEndpoint + .cast(endpoint) + .map(httpEndpoint => + new SmithyPlayClientEndpoint(endpoint, baseUri, additionalHeaders, httpEndpoint, input).send() + ) + .get + } + +} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala new file mode 100644 index 00000000..769f28e8 --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala @@ -0,0 +1,108 @@ +package de.innfactory +package smithy4play +package client +import smithy4s.http.json.codecs +import smithy4s.{ Endpoint, HintMask, Schema } +import smithy4s.http.{ CaseInsensitive, CodecAPI, HttpEndpoint, Metadata, MetadataError, PayloadError } +import cats.implicits._ +import smithy4s.internals.InputOutput + +import scala.concurrent.{ ExecutionContext, Future } + +private[smithy4play] class SmithyPlayClientEndpoint[Op[_, _, _, _, _], I, E, O, SI, SO]( + endpoint: Endpoint[Op, I, E, O, SI, SO], + baseUri: String, + additionalHeaders: Option[Map[String, Seq[String]]], + httpEndpoint: HttpEndpoint[I], + input: I +)(implicit executionContext: ExecutionContext, client: RequestClient) { + + private val codecs: codecs = + smithy4s.http.json.codecs(smithy4s.api.SimpleRestJson.protocol.hintMask ++ HintMask(InputOutput)) + + private val inputSchema: Schema[I] = endpoint.input + private val outputSchema: Schema[O] = endpoint.output + + private val inputMetadataEncoder = + Metadata.Encoder.fromSchema(inputSchema) + private val outputMetadataDecoder = + Metadata.PartialDecoder.fromSchema(outputSchema) + private val inputHasBody = + Metadata.TotalDecoder.fromSchema(inputSchema).isEmpty + + def send( + ): ClientResponse[O] = { + val metadata = inputMetadataEncoder.encode(input) + val path = buildPath(metadata) + val headers = metadata.headers.map(x => (x._1.toString, x._2)) + val headersWithAuth = if (additionalHeaders.isDefined) headers.combine(additionalHeaders.get) else headers + val code = httpEndpoint.code + val codecApi: CodecAPI = extractCodec(headers) + val send = client.send(httpEndpoint.method.toString, path, headersWithAuth, _) + val response = if (inputHasBody) { + val codec = codecApi.compileCodec(inputSchema) + val bodyEncoded = codecApi.writeToArray(codec, input) + send(Some(bodyEncoded)) + } else send(None) + decodeResponse(response, code) + } + + private def extractCodec(headers: Map[String, Seq[String]]): CodecAPI = { + val contentType = + headers.getOrElse("Content-Type", List("application/json")) + val codecApi = contentType match { + case List("application/json") => codecs + case _ => CodecAPI.nativeStringsAndBlob(codecs) + } + codecApi + } + + private def decodeResponse( + response: Future[SmithyClientResponse], + expectedCode: Int + ): ClientResponse[O] = + for { + res <- response + metadata = Metadata(headers = res.headers.map(headers => (CaseInsensitive(headers._1), headers._2))) + output <- if (res.statusCode == expectedCode) handleSuccess(metadata, res, expectedCode) + else handleError(res, expectedCode) + } yield output + + def handleSuccess(metadata: Metadata, response: SmithyClientResponse, expectedCode: Int) = { + val headers = response.headers + val output = outputMetadataDecoder.total match { + case Some(totalDecoder) => + totalDecoder.decode(metadata) + case None => + for { + metadataPartial <- outputMetadataDecoder.decode(metadata) + codecApi = extractCodec(headers) + bodyPartial <- + codecApi.decodeFromByteArrayPartial(codecApi.compileCodec(outputSchema), response.body.get) + } yield metadataPartial.combine(bodyPartial) + } + Future( + output.map(o => SmithyPlayClientEndpointResponse(Some(o), headers, response.statusCode, expectedCode)).left.map { + case error: PayloadError => + SmithyPlayClientEndpointErrorResponse(error.expected.getBytes, response.statusCode, expectedCode) + case error: MetadataError => + SmithyPlayClientEndpointErrorResponse(error.getMessage().getBytes(), response.statusCode, expectedCode) + } + ) + } + def handleError(response: SmithyClientResponse, expectedCode: Int) = Future( + Left { + SmithyPlayClientEndpointErrorResponse( + response.body.getOrElse(Array.emptyByteArray), + response.statusCode, + expectedCode + ) + } + ) + + def buildPath(metadata: Metadata): String = + baseUri + httpEndpoint.path(input).mkString("/") + metadata.queryFlattened + .map(s => s._1 + "=" + s._2) + .mkString("?", "&", "") + +} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointErrorResponse.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointErrorResponse.scala new file mode 100644 index 00000000..9c0310f3 --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointErrorResponse.scala @@ -0,0 +1,7 @@ +package de.innfactory.smithy4play.client + +case class SmithyPlayClientEndpointErrorResponse( + error: Array[Byte], + statusCode: Int, + expectedStatusCode: Int +) diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointResponse.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointResponse.scala new file mode 100644 index 00000000..4efefab3 --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointResponse.scala @@ -0,0 +1,8 @@ +package de.innfactory.smithy4play.client + +case class SmithyPlayClientEndpointResponse[O]( + body: Option[O], + headers: Map[String, Seq[String]], + statusCode: Int, + expectedStatusCode: Int +) diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala new file mode 100644 index 00000000..69f71a08 --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala @@ -0,0 +1,36 @@ +package de.innfactory.smithy4play.client + +import de.innfactory.smithy4play.ClientResponse +import play.api.libs.json.Json + +import scala.concurrent.duration.{ Duration, DurationInt } +import scala.concurrent.{ Await, ExecutionContext, Future } + +object SmithyPlayTestUtils { + + implicit class EnhancedResponse[O](response: ClientResponse[O]) { + def awaitRight(implicit + ec: ExecutionContext, + timeout: Duration = 5.seconds + ): SmithyPlayClientEndpointResponse[O] = + Await.result( + response.map(_.toOption.get), + timeout + ) + + def awaitLeft(implicit + ec: ExecutionContext, + timeout: Duration = 5.seconds, + errorAsString: Boolean = true + ): SmithyPlayClientEndpointErrorResponse = + Await.result( + response.map(_.left.toOption.get), + timeout + ) + } + + implicit class EnhancedByteArray(error: Array[Byte]) { + def toErrorString: String = new String(error) + } + +} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala index 0a4a7af3..38a22386 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala @@ -1,10 +1,12 @@ package de.innfactory import cats.data.{ EitherT, Kleisli } +import de.innfactory.smithy4play.client.{ SmithyPlayClientEndpointErrorResponse, SmithyPlayClientEndpointResponse } import org.slf4j import play.api.Logger -import play.api.mvc.RequestHeader -import smithy4s.http.{ CaseInsensitive, HttpEndpoint } +import play.api.mvc.{ Headers, RequestHeader } +import smithy4s.http.{ CaseInsensitive, HttpEndpoint, PayloadError } + import scala.language.experimental.macros import scala.annotation.{ compileTimeOnly, StaticAnnotation } import scala.concurrent.Future @@ -18,6 +20,8 @@ package object smithy4play { def statusCode: Int } + type ClientResponse[O] = Future[Either[SmithyPlayClientEndpointErrorResponse, SmithyPlayClientEndpointResponse[O]]] + type RouteResult[O] = EitherT[Future, ContextRouteError, O] type ContextRoute[O] = Kleisli[RouteResult, RoutingContext, O] @@ -33,8 +37,8 @@ package object smithy4play { private[smithy4play] val logger: slf4j.Logger = Logger("smithy4play").logger - private[smithy4play] def getHeaders(req: RequestHeader): Map[CaseInsensitive, Seq[String]] = - req.headers.headers.groupBy(_._1).map { case (k, v) => + private[smithy4play] def getHeaders(headers: Headers): Map[CaseInsensitive, Seq[String]] = + headers.headers.groupBy(_._1).map { case (k, v) => (CaseInsensitive(k), v.map(_._2)) } diff --git a/smithy4playTest/.gitignore b/smithy4playTest/.gitignore new file mode 100644 index 00000000..716038bf --- /dev/null +++ b/smithy4playTest/.gitignore @@ -0,0 +1 @@ +*/testDefinitions/* \ No newline at end of file diff --git a/smithy4playTest/app/controller/TestController.scala b/smithy4playTest/app/controller/TestController.scala new file mode 100644 index 00000000..7a0cccfa --- /dev/null +++ b/smithy4playTest/app/controller/TestController.scala @@ -0,0 +1,58 @@ +package controller + +import cats.data.{ EitherT, Kleisli } +import de.innfactory.smithy4play.{ AutoRouting, ContextRoute, ContextRouteError } +import play.api.mvc.ControllerComponents +import smithy4s.ByteArray +import testDefinitions.test._ + +import javax.inject.{ Inject, Singleton } +import scala.concurrent.{ ExecutionContext, Future } + +@Singleton +@AutoRouting +class TestController @Inject() (implicit + cc: ControllerComponents, + executionContext: ExecutionContext +) extends TestControllerService[ContextRoute] { + + override def test(): ContextRoute[SimpleTestResponse] = Kleisli { rc => + EitherT.rightT[Future, ContextRouteError](SimpleTestResponse(Some("TestWithSimpleResponse"))) + } + + override def testWithOutput( + pathParam: String, + testQuery: String, + testHeader: String, + body: TestRequestBody + ): ContextRoute[TestWithOutputResponse] = + Kleisli { rc => + EitherT.rightT[Future, ContextRouteError]( + TestWithOutputResponse(TestResponseBody(testHeader, pathParam, testQuery, body.message)) + ) + } + + override def health(): ContextRoute[Unit] = Kleisli { rc => + EitherT.rightT[Future, ContextRouteError](()) + } + + override def testWithBlob(body: ByteArray, contentType: String): ContextRoute[BlobResponse] = Kleisli { rc => + EitherT.rightT[Future, ContextRouteError](BlobResponse(body, "image/png")) + } + + override def testWithQuery(testQuery: String): ContextRoute[Unit] = Kleisli { rc => + EitherT.rightT[Future, ContextRouteError](()) + } + + override def testThatReturnsError(): ContextRoute[Unit] = Kleisli { rc => + EitherT.leftT[Future, Unit](new ContextRouteError { + override def message: String = "this is supposed to fail" + + override def additionalInfoToLog: Option[String] = None + + override def additionalInfoErrorCode: Option[String] = None + + override def statusCode: Int = 500 + }) + } +} diff --git a/smithy4playTest/conf/application.conf b/smithy4playTest/conf/application.conf new file mode 100644 index 00000000..9d7249de --- /dev/null +++ b/smithy4playTest/conf/application.conf @@ -0,0 +1,11 @@ +# https://www.playframework.com/documentation/latest/Configuration + + +play.http.parser.maxDiskBuffer = 100MB +play.http.parser.maxMemoryBuffer=100MB +parsers.anyContent.maxLength = 100MB +play.filters.enabled = [] + +smithy4play.autoRoutePackage = "controller" + +play.http.secret.key = dkjfgherkgjerhgk \ No newline at end of file diff --git a/smithy4playTest/conf/logback.xml b/smithy4playTest/conf/logback.xml new file mode 100644 index 00000000..ac8878e0 --- /dev/null +++ b/smithy4playTest/conf/logback.xml @@ -0,0 +1,42 @@ + + + + + + + ${application.home:-.}/logs/application.log + + %date [%level] from %logger in %thread - %message%n%xException + + + + + + %coloredLevel %logger{15} - %message%n%xException{10} + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/smithy4playTest/conf/messages b/smithy4playTest/conf/messages new file mode 100644 index 00000000..6d98fb4f --- /dev/null +++ b/smithy4playTest/conf/messages @@ -0,0 +1 @@ +# https://www.playframework.com/documentation/latest/ScalaI18N diff --git a/smithy4playTest/conf/routes b/smithy4playTest/conf/routes new file mode 100644 index 00000000..2152173a --- /dev/null +++ b/smithy4playTest/conf/routes @@ -0,0 +1,8 @@ +# Routes +# This file defines all application routes (Higher priority routes first) +# https://www.playframework.com/documentation/latest/ScalaRouting +# ~~~~ + +# An example controller showing a sample home page +#-> /1 de.innfactory.smithy4play.AutoRouter +-> / de.innfactory.smithy4play.AutoRouter diff --git a/smithy4playTest/logs/application.log b/smithy4playTest/logs/application.log new file mode 100644 index 00000000..e69de29b diff --git a/smithy4playTest/test/TestControllerClient.scala b/smithy4playTest/test/TestControllerClient.scala new file mode 100644 index 00000000..35620b99 --- /dev/null +++ b/smithy4playTest/test/TestControllerClient.scala @@ -0,0 +1,49 @@ +import de.innfactory.smithy4play.ClientResponse +import de.innfactory.smithy4play.client.{ RequestClient, SmithyPlayClient } +import smithy4s.ByteArray +import testDefinitions.test.{ + BlobRequest, + BlobResponse, + QueryRequest, + SimpleTestResponse, + TestControllerService, + TestControllerServiceGen, + TestRequestBody, + TestRequestWithQueryAndPathParams, + TestWithOutputResponse +} + +import scala.concurrent.ExecutionContext + +class TestControllerClient(additionalHeaders: Map[String, Seq[String]] = Map.empty, baseUri: String = "/")(implicit + ec: ExecutionContext, + client: RequestClient +) extends TestControllerService[ClientResponse] { + + val smithyPlayClient = new SmithyPlayClient(baseUri, TestControllerService.service) + + override def test(): ClientResponse[SimpleTestResponse] = + smithyPlayClient.send(TestControllerServiceGen.Test(), Some(additionalHeaders)) + + override def testWithOutput( + pathParam: String, + testQuery: String, + testHeader: String, + body: TestRequestBody + ): ClientResponse[TestWithOutputResponse] = smithyPlayClient.send( + TestControllerServiceGen.TestWithOutput(TestRequestWithQueryAndPathParams(pathParam, testQuery, testHeader, body)), + Some(additionalHeaders ++ Map("Content-Type" -> Seq("application/json"))) + ) + + override def health(): ClientResponse[Unit] = + smithyPlayClient.send(TestControllerServiceGen.Health(), Some(additionalHeaders)) + + override def testWithBlob(body: ByteArray, contentType: String): ClientResponse[BlobResponse] = + smithyPlayClient.send(TestControllerServiceGen.TestWithBlob(BlobRequest(body, contentType)), Some(additionalHeaders)) + + override def testWithQuery(testQuery: String): ClientResponse[Unit] = + smithyPlayClient.send(TestControllerServiceGen.TestWithQuery(QueryRequest(testQuery)), Some(additionalHeaders)) + + override def testThatReturnsError(): ClientResponse[Unit] = + smithyPlayClient.send(TestControllerServiceGen.TestThatReturnsError(), Some(additionalHeaders)) +} diff --git a/smithy4playTest/test/TestControllerTest.scala b/smithy4playTest/test/TestControllerTest.scala new file mode 100644 index 00000000..7a71c981 --- /dev/null +++ b/smithy4playTest/test/TestControllerTest.scala @@ -0,0 +1,131 @@ +import de.innfactory.smithy4play.client.{RequestClient, SmithyClientResponse} +import de.innfactory.smithy4play.client.SmithyPlayTestUtils._ +import org.scalatestplus.play.{BaseOneAppPerSuite, FakeApplicationFactory, PlaySpec} +import play.api.Application +import play.api.Play.materializer +import play.api.inject.guice.GuiceApplicationBuilder +import play.api.libs.json.{Json, OWrites} +import play.api.mvc.{AnyContentAsEmpty, Result} +import play.api.test.FakeRequest +import play.api.test.Helpers._ +import testDefinitions.test.TestRequestBody +import smithy4s.ByteArray + +import java.io.File +import java.nio.file.Files +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class TestControllerTest extends PlaySpec with BaseOneAppPerSuite with FakeApplicationFactory { + + implicit object FakeRequestClient extends RequestClient { + override def send( + method: String, + path: String, + headers: Map[String, Seq[String]], + body: Option[Array[Byte]] + ): Future[SmithyClientResponse] = { + val baseRequest: FakeRequest[AnyContentAsEmpty.type] = FakeRequest(method, path) + .withHeaders(headers.toList.flatMap(headers => headers._2.map(v => (headers._1, v))): _*) + val res = + if (body.isDefined) route(app, baseRequest.withBody(body.get)).get + else + route( + app, + baseRequest + ).get + + for { + result <- res + headers = result.header.headers.map(v => (v._1, Seq(v._2))) + body <- result.body.consumeData.map(_.toArrayUnsafe()) + bodyConsumed = if (result.body.isKnownEmpty) None else Some(body) + contentType = result.body.contentType + headersWithContentType = + if (contentType.isDefined) headers + ("Content-Type" -> Seq(contentType.get)) else headers + } yield SmithyClientResponse(bodyConsumed, headersWithContentType, result.header.status) + + } + } + + val testControllerClient = new TestControllerClient() + + override def fakeApplication(): Application = + new GuiceApplicationBuilder().build() + + "controller.TestController" must { + + "route to Test Endpoint" in { + + val result = testControllerClient.test().awaitRight + + result.statusCode mustBe result.expectedStatusCode + } + + "route to Test Endpoint by SmithyTestClient with Query Parameter, Path Parameter and Body" in { + val pathParam = "thisIsAPathParam" + val testQuery = "thisIsATestQuery" + val testHeader = "thisIsATestHeader" + val body = TestRequestBody("thisIsARequestBody") + val result = testControllerClient.testWithOutput(pathParam, testQuery, testHeader, body).awaitRight + + val responseBody = result.body.get + result.statusCode mustBe result.expectedStatusCode + responseBody.body.testQuery mustBe testQuery + responseBody.body.pathParam mustBe pathParam + responseBody.body.bodyMessage mustBe body.message + responseBody.body.testHeader mustBe testHeader + } + + "route to Test Endpoint but should return error because required header is not set" in { + val pathParam = "thisIsAPathParam" + val testQuery = "thisIsATestQuery" + val testHeader = "thisIsATestHeader" + val body = TestRequestBody("thisIsARequestBody") + implicit val format: OWrites[TestRequestBody] = Json.writes[TestRequestBody] + val future: Future[Result] = + route( + app, + FakeRequest("POST", s"/test/$pathParam?testQuery=$testQuery") + .withHeaders(("Wrong-Header", testHeader)) + .withBody(Json.toJson(body)) + ).get + + status(future) mustBe 500 + } + + "route to Query Endpoint but should return error because query is not set" in { + val testQuery = "thisIsATestQuery" + implicit val format: OWrites[TestRequestBody] = Json.writes[TestRequestBody] + val future: Future[Result] = + route( + app, + FakeRequest("GET", s"/query?WrongQuery=$testQuery") + ).get + + status(future) mustBe 500 + } + + "route to Health Endpoint" in { + val result = testControllerClient.health().awaitRight + + result.statusCode mustBe result.expectedStatusCode + } + + "route to error Endpoint" in { + val result = testControllerClient.testThatReturnsError().awaitLeft + + result.error.toErrorString must include ("fail") + result.statusCode mustBe 500 + } + + "route to Blob Endpoint" in { + val path = getClass.getResource("/testPicture.png").getPath + val file = new File(path) + val pngAsBytes = ByteArray(Files.readAllBytes(file.toPath)) + val result = testControllerClient.testWithBlob(pngAsBytes, "image/png").awaitRight + + result.statusCode mustBe result.expectedStatusCode + } + } +} diff --git a/smithy4playTest/test/resources/testPicture.png b/smithy4playTest/test/resources/testPicture.png new file mode 100644 index 00000000..ff651e9f Binary files /dev/null and b/smithy4playTest/test/resources/testPicture.png differ diff --git a/smithy4playTest/testSpecs/TestController.smithy b/smithy4playTest/testSpecs/TestController.smithy new file mode 100644 index 00000000..c5323d41 --- /dev/null +++ b/smithy4playTest/testSpecs/TestController.smithy @@ -0,0 +1,113 @@ +$version: "2" +namespace testDefinitions.test + +use smithy4s.api#simpleRestJson + + +@simpleRestJson +service TestControllerService { + version: "0.0.1", + operations: [Test, TestWithOutput, Health, TestWithBlob, TestWithQuery, TestThatReturnsError] +} + +@http(method: "POST", uri: "/blob", code: 200) +operation TestWithBlob { + input: BlobRequest, + output: BlobResponse +} + +@readonly +@http(method: "GET", uri: "/error", code: 200) +operation TestThatReturnsError { +} + +@readonly +@http(method: "GET", uri: "/query", code: 200) +operation TestWithQuery { + input: QueryRequest +} + +structure QueryRequest { + @httpQuery("testQuery") + @required + testQuery: String + +} + +structure BlobRequest { + @httpPayload + @required + body: Blob, + @httpHeader("Content-Type") + @required + contentType: String +} + +structure BlobResponse { + @httpPayload + @required + body: Blob, + @httpHeader("Content-Type") + @required + contentType: String +} + +@readonly +@http(method: "GET", uri: "/health", code: 200) +operation Health { +} + +@readonly +@http(method: "GET", uri: "/", code: 200) +operation Test { + output: SimpleTestResponse +} + +@http(method: "POST", uri: "/test/{pathParam}", code: 200) +operation TestWithOutput { + input: TestRequestWithQueryAndPathParams, + output: TestWithOutputResponse +} + +structure SimpleTestResponse { + message: String +} + +structure TestRequestWithQueryAndPathParams { + @httpLabel + @required + pathParam: String, + @httpQuery("testQuery") + @required + testQuery: String, + @httpHeader("Test-Header") + @required + testHeader: String, + @httpPayload + @required + body: TestRequestBody +} + +structure TestRequestBody { + @required + message: String +} + +structure TestWithOutputResponse { + @httpPayload + @required + body: TestResponseBody +} + +structure TestResponseBody { + @required + testHeader: String, + @required + pathParam: String, + @required + testQuery: String, + @required + bodyMessage: String +} + +