diff --git a/build.sbt b/build.sbt index 2903e33b..1f312631 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ import sbt.Compile import sbt.Keys.cleanFiles -val releaseVersion = sys.env.getOrElse("TAG", "0.4.4-Delta") +val releaseVersion = sys.env.getOrElse("TAG", "1.0.0-Gamma") addCommandAlias("publishSmithy4Play", "smithy4play/publish") addCommandAlias("publishLocalSmithy4Play", "smithy4play/publishLocal") addCommandAlias("generateCoverage", "clean; coverage; test; coverageReport") @@ -29,15 +29,15 @@ val defaultProjectSettings = Seq( version := releaseVersion ) ++ githubSettings -val sharedSettings = defaultProjectSettings - +val sharedSettings = defaultProjectSettings lazy val smithy4play = project .in(file("smithy4play")) .enablePlugins(Smithy4sCodegenPlugin) .settings( sharedSettings, + addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.2" cross CrossVersion.full), scalaVersion := Dependencies.scalaVersion, - Compile / smithy4sAllowedNamespaces := List("smithy.smithy4play"), + Compile / smithy4sAllowedNamespaces := List("smithy.smithy4play", "aws.protocols"), Compile / smithy4sInputDirs := Seq( (ThisBuild / baseDirectory).value / "smithy4play" / "src" / "resources" / "META_INF" / "smithy" ), @@ -53,14 +53,15 @@ lazy val smithy4playTest = project .enablePlugins(Smithy4sCodegenPlugin, PlayScala) .settings( sharedSettings, - scalaVersion := Dependencies.scalaVersion, - name := "smithy4playTest", + 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 / smithy4sInputDirs := Seq((ThisBuild / baseDirectory).value / "smithy4playTest" / "testSpecs"), - Compile / smithy4sOutputDir := (ThisBuild / baseDirectory).value / "smithy4playTest" / "app", + cleanFiles += (ThisBuild / baseDirectory).value / "smithy4playTest" / "app" / "specs" / "testDefinitions" / "test", + Compile / smithy4sInputDirs := Seq((ThisBuild / baseDirectory).value / "smithy4playTest" / "testSpecs"), + Compile / smithy4sOutputDir := (ThisBuild / baseDirectory).value / "smithy4playTest" / "app" / "specs", + Compile / smithy4sAllowedNamespaces := List("aws.protocols", "testDefinitions.test"), libraryDependencies ++= Seq( guice, Dependencies.cats, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 460050b7..6d685c4d 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -7,9 +7,10 @@ object Dependencies { val typesafePlay = "com.typesafe.play" %% "play" % playVersion val scalaVersion = "2.13.12" - val smithy4sVersion = "0.17.19" + val smithy4sVersion = "0.18.8" val smithyCore = "com.disneystreaming.smithy4s" %% "smithy4s-core" % smithy4sVersion val smithyJson = "com.disneystreaming.smithy4s" %% "smithy4s-json" % smithy4sVersion + val smithyXml = "com.disneystreaming.smithy4s" %% "smithy4s-xml" % smithy4sVersion val smithy4sCompliance = "com.disneystreaming.smithy4s" %% "smithy4s-compliance-tests" % smithy4sVersion val alloyCore = "com.disneystreaming.alloy" % "alloy-core" % "0.2.8" val alloyOpenapi = "com.disneystreaming.alloy" %% "alloy-openapi" % "0.2.8" @@ -26,6 +27,7 @@ object Dependencies { lazy val list = Seq( smithyCore, smithyJson, + smithyXml, alloyCore, alloyOpenapi, smithy4sCompliance, diff --git a/project/plugins.sbt b/project/plugins.sbt index 0505db9d..8d4bdd00 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,7 @@ addSbtPlugin("com.codecommit" %% "sbt-github-packages" % "0.5.3") addSbtPlugin("org.wartremover" %% "sbt-wartremover" % "3.1.5") addSbtPlugin("org.scalameta" %% "sbt-scalafmt" % "2.5.2") -addSbtPlugin("com.disneystreaming.smithy4s" %% "smithy4s-sbt-codegen" % "0.17.19") +addSbtPlugin("com.disneystreaming.smithy4s" %% "smithy4s-sbt-codegen" % "0.18.8") addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.9.1") addSbtPlugin("org.scoverage" %% "sbt-scoverage" % "2.0.9") diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala new file mode 100644 index 00000000..f6b9c481 --- /dev/null +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala @@ -0,0 +1,138 @@ +package de.innfactory.smithy4play + +import play.api.http.MimeTypes +import smithy4s.capability.instances.either._ +import smithy4s.codecs.Writer.CachedCompiler +import smithy4s.codecs._ +import smithy4s.http.{ HttpResponse, HttpRestSchema, Metadata, MetadataError } +import smithy4s.json.Json +import smithy4s.kinds.PolyFunction +import smithy4s.schema.CachedSchemaCompiler +import smithy4s.xml.Xml +import smithy4s.{ codecs, Blob } + +object CodecDecider { + + private val jsonCodecs = Json.payloadCodecs + .withJsoniterCodecCompiler( + Json.jsoniter + ) + + private val jsonEncoder: BlobEncoder.Compiler = jsonCodecs.encoders + private val jsonDecoder: BlobDecoder.Compiler = jsonCodecs.decoders + private val metadataEncoder = Metadata.Encoder + private val metadataDecoder: CachedSchemaCompiler[Metadata.Decoder] = Metadata.Decoder + + def encoder( + contentType: Seq[String] + ): CachedSchemaCompiler[codecs.BlobEncoder] = + contentType match { + case Seq(MimeTypes.JSON) => jsonEncoder + case Seq(MimeTypes.XML) => Xml.encoders + case _ => + CachedSchemaCompiler + .getOrElse(smithy4s.codecs.StringAndBlobCodecs.encoders, jsonEncoder) + } + + def requestDecoder( + contentType: Seq[String] + ): CachedSchemaCompiler[Decoder[Either[Throwable, *], PlayHttpRequest[Blob], *]] = + HttpRestSchema.combineDecoderCompilers[Either[Throwable, *], PlayHttpRequest[Blob]]( + metadataDecoder + .mapK( + Decoder.in[Either[MetadataError, *]].composeK[Metadata, PlayHttpRequest[Blob]](_.metadata) + ) + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], PlayHttpRequest[Blob], *]]], + decoder(contentType) + .mapK( + Decoder.in[Either[PayloadError, *]].composeK[Blob, PlayHttpRequest[Blob]](_.body) + ) + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], PlayHttpRequest[Blob], *]]], + _ => Right(()) + )(eitherZipper) + + def requestEncoder( + contentType: Seq[String] + ): CachedCompiler[EndpointRequest] = + HttpRestSchema.combineWriterCompilers( + metadataEncoder.mapK( + metadataPipe + ), + encoder(contentType).mapK( + blobPipe + ), + _ => false + ) + + def httpResponseDecoder( + contentType: Seq[String] + ): CachedSchemaCompiler[Decoder[Either[Throwable, *], HttpResponse[Blob], *]] = + HttpRestSchema.combineDecoderCompilers[Either[Throwable, *], HttpResponse[Blob]]( + metadataDecoder + .mapK( + Decoder + .in[Either[MetadataError, *]] + .composeK[Metadata, HttpResponse[Blob]](r => + Metadata(Map.empty, Map.empty, headers = r.headers, statusCode = Some(r.statusCode)) + ) + ) + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], HttpResponse[Blob], *]]], + decoder(contentType) + .mapK( + Decoder.in[Either[PayloadError, *]].composeK[Blob, HttpResponse[Blob]](_.body) + ) + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], HttpResponse[Blob], *]]], + _ => Right(()) + )(eitherZipper) + + def httpMessageEncoder( + contentType: Seq[String] + ): CachedCompiler[HttpResponse[Blob]] = + HttpRestSchema.combineWriterCompilers( + metadataEncoder.mapK( + httpRequestMetadataPipe + ), + encoder(contentType).mapK( + httpRequestBlobPipe + ), + _ => false + ) + + private val httpRequestBodyLift: Writer[HttpResponse[Blob], Blob] = + Writer.lift[HttpResponse[Blob], Blob]((res, blob) => res.copy(body = blob)) + private val httpRequestMetadataLift: Writer[HttpResponse[Blob], Metadata] = + Writer.lift[HttpResponse[Blob], Metadata]((res, metadata) => + res.addHeaders(metadata.headers.map { case (insensitive, value) => + (insensitive, value) + }) + ) + private val httpRequestBlobPipe: PolyFunction[Encoder[Blob, *], Writer[HttpResponse[Blob], *]] = + smithy4s.codecs.Encoder.pipeToWriterK[HttpResponse[Blob], Blob](httpRequestBodyLift) + private val httpRequestMetadataPipe: PolyFunction[Encoder[Metadata, *], Writer[HttpResponse[Blob], *]] = + smithy4s.codecs.Encoder.pipeToWriterK(httpRequestMetadataLift) + + private val blobLift: Writer[EndpointRequest, Blob] = + Writer.lift[EndpointRequest, Blob]((res, blob) => res.copy(body = blob)) + private val metadataLift: Writer[EndpointRequest, Metadata] = + Writer.lift[EndpointRequest, Metadata]((res, metadata) => + res.addHeaders(metadata.headers.map { case (insensitive, value) => + (insensitive, value) + }) + ) + private val blobPipe: PolyFunction[Encoder[Blob, *], Writer[EndpointRequest, *]] = + smithy4s.codecs.Encoder.pipeToWriterK[EndpointRequest, Blob](blobLift) + private val metadataPipe: PolyFunction[Encoder[Metadata, *], Writer[EndpointRequest, *]] = + smithy4s.codecs.Encoder.pipeToWriterK(metadataLift) + + def decoder( + contentType: Seq[String] + ): CachedSchemaCompiler[BlobDecoder] = + contentType match { + case Seq(MimeTypes.JSON) => jsonDecoder + case Seq(MimeTypes.XML) => Xml.decoders + case _ => + CachedSchemaCompiler + .getOrElse(smithy4s.codecs.StringAndBlobCodecs.decoders, jsonDecoder) + } + +} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecUtils.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecUtils.scala deleted file mode 100644 index f3c1b330..00000000 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecUtils.scala +++ /dev/null @@ -1,35 +0,0 @@ -package de.innfactory.smithy4play - -import smithy4s.{ HintMask, Schema } -import smithy4s.http.{ CodecAPI, PayloadError } -import smithy4s.http.json.codecs -import smithy4s.internals.InputOutput - -object CodecUtils { - - private val codecs: codecs = - smithy4s.http.json.codecs(alloy.SimpleRestJson.protocol.hintMask ++ HintMask(InputOutput)) - - def writeInputToBody[I](input: I, inputSchema: Schema[I], codecAPI: CodecAPI): Array[Byte] = { - val codec = codecAPI.compileCodec(inputSchema) - codecAPI.writeToArray(codec, input) - } - - def readFromBytes[E](data: Array[Byte], inputSchema: Schema[E], codecAPI: CodecAPI): Either[PayloadError, E] = - codecAPI.decodeFromByteArray(codecAPI.compileCodec(inputSchema), data) - - def writeEntityToJsonBytes[E](e: E, schema: Schema[E]) = writeInputToBody[E](e, schema, codecs) - - def readFromJsonBytes[E](bytes: Array[Byte], schema: Schema[E]): Option[E] = - readFromBytes(bytes, schema, codecs).toOption - - 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 - } -} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala index 2d2c6258..9ca51aeb 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala @@ -1,15 +1,17 @@ package de.innfactory.smithy4play -import akka.util.ByteString +import alloy.SimpleRestJson +import aws.protocols.RestXml import cats.data.{ EitherT, Kleisli } -import cats.implicits.toBifunctorOps import de.innfactory.smithy4play import de.innfactory.smithy4play.middleware.MiddlewareBase +import play.api.http.MimeTypes import play.api.mvc._ -import smithy4s.http.{ CodecAPI, HttpEndpoint, Metadata, PathParams } +import smithy4s.codecs.PayloadError +import smithy4s.http._ import smithy4s.kinds.FunctorInterpreter import smithy4s.schema.Schema -import smithy4s.{ ByteArray, Endpoint, Service } +import smithy4s.{ Blob, Endpoint, Service } import scala.concurrent.{ ExecutionContext, Future } @@ -23,23 +25,17 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[ service: Service[Alg], impl: FunctorInterpreter[Op, F], middleware: Seq[MiddlewareBase], - endpoint: Endpoint[Op, I, E, O, SI, SO], - codecs: CodecAPI + endpoint: Endpoint[Op, I, E, O, SI, SO] )(implicit cc: ControllerComponents, ec: ExecutionContext) extends AbstractController(cc) { - private val httpEndpoint: Either[HttpEndpoint.HttpEndpointError, HttpEndpoint[I]] = HttpEndpoint.cast(endpoint) + private val httpEndpoint: Either[HttpEndpoint.HttpEndpointError, HttpEndpoint[I]] = HttpEndpoint.cast(endpoint.schema) private val serviceHints = service.hints private val endpointHints = endpoint.hints + private val serviceContentType: String = serviceHints.toMimeType - private val inputSchema: Schema[I] = endpoint.input - private val outputSchema: Schema[O] = endpoint.output - - private val inputMetadataDecoder: Metadata.PartialDecoder[I] = - Metadata.PartialDecoder.fromSchema(inputSchema) - - private val outputMetadataEncoder: Metadata.Encoder[O] = - Metadata.Encoder.fromSchema(outputSchema) + private implicit val inputSchema: Schema[I] = endpoint.input + private implicit val outputSchema: Schema[O] = endpoint.output def handler(v1: RequestHeader): Handler = httpEndpoint.map { httpEp => @@ -52,14 +48,14 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[ ) } - val result = for { - pathParams <- getPathParams(v1, httpEp) - metadata = getMetadata(pathParams, v1) - input <- getInput(request, metadata) - endpointLogic = impl(endpoint.wrap(input)) - .asInstanceOf[Kleisli[RouteResult, RoutingContext, O]] - .map(mapToEndpointResult(httpEp.code)) - + implicit val epContentType: ContentType = ContentType(request.contentType.getOrElse(serviceContentType)) + val result = for { + pathParams <- getPathParams(v1, httpEp) + metadata = getMetadata(pathParams, v1) + input <- getInput(request, metadata) + endpointLogic = impl(endpoint.wrap(input)) + .asInstanceOf[Kleisli[RouteResult, RoutingContext, O]] + .map(mapToEndpointResult(httpEp.code)) chainedMiddlewares = middleware.foldRight(endpointLogic)((a, b) => a.middleware(b.run)) res <- chainedMiddlewares.run(RoutingContext.fromRequest(request, serviceHints, endpointHints, v1)) @@ -72,39 +68,33 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[ } .getOrElse(Action(NotFound("404"))) - private def mapToEndpointResult(statusCode: Int)(o: O): EndpointResult = { - val outputMetadata = outputMetadataEncoder.encode(o) - val outputHeaders = outputMetadata.headers.map { case (k, v) => - (k.toString.toLowerCase, v.mkString("")) - } - val contentType = - outputHeaders.getOrElse("content-type", "application/json") - val codecApi = contentType match { - case "application/json" => codecs - case _ => CodecAPI.nativeStringsAndBlob(codecs) - } - logger.debug(s"[SmithyPlayEndpoint] Headers: ${outputHeaders.mkString("|")}") - - val codec = codecApi.compileCodec(outputSchema) - val expectBody = Metadata.PartialDecoder + private def mapToEndpointResult( + statusCode: Int + )(output: O)(implicit defaultContentType: ContentType): HttpResponse[Blob] = + CodecDecider + .httpMessageEncoder(Seq(defaultContentType.value)) .fromSchema(outputSchema) - .total - .isEmpty // expect body if metadata decoder is not total - val body = if (expectBody) Some(codecApi.writeToArray(codec, o)) else None - EndpointResult(body, status = smithy4play.Status(outputHeaders, statusCode)) - } + .write( + HttpResponse( + statusCode = statusCode, + headers = Map.empty, + body = Blob.empty + ), + output + ) private def getPathParams( v1: RequestHeader, httpEp: HttpEndpoint[I] - ): EitherT[Future, ContextRouteError, Map[String, String]] = + )(implicit defaultContentType: ContentType): EitherT[Future, ContextRouteError, Map[String, String]] = EitherT( Future( matchRequestPath(v1, httpEp) .toRight[ContextRouteError]( Smithy4PlayError( "Error in extracting PathParams", - smithy4play.Status(Map.empty, 400) + smithy4play.Status(Map.empty, 400), + contentType = defaultContentType.value ) ) ) @@ -113,84 +103,34 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[ private def getInput( request: Request[RawBuffer], metadata: Metadata - ): EitherT[Future, ContextRouteError, I] = - EitherT( - Future(inputMetadataDecoder.total match { - case Some(value) => - value - .decode(metadata) - .leftMap { e => - logger.info(e.getMessage()) - Smithy4PlayError( - "Error decoding Input", - smithy4play.Status(Map.empty, 500) - ) - } - case None => - request.contentType.getOrElse("application/json") match { - case "application/json" => parseJson(request, metadata) - case _ => parseRaw(request, metadata) - } - - }) - ) - - private def parseJson(request: Request[RawBuffer], metadata: Metadata): Either[ContextRouteError, I] = { - val codec = codecs.compileCodec(inputSchema) - for { - metadataPartial <- inputMetadataDecoder - .decode(metadata) - .leftMap { e => - logger.info(e.getMessage()) - Smithy4PlayError( - "Error decoding Input Metadata", - smithy4play.Status(Map.empty, 500) - ) - } - c <- - codecs - .decodeFromByteBufferPartial( - codec, - request.body.asBytes().getOrElse(ByteString.empty).toByteBuffer - ) - .leftMap(e => - Smithy4PlayError( - s"expected: ${e.expected}", - smithy4play.Status(Map.empty, 500), - additionalInformation = Some(e.getMessage()) - ) - ) - } yield metadataPartial.combine(c) - } - - private def parseRaw(request: Request[RawBuffer], metadata: Metadata): Either[ContextRouteError, I] = { - val nativeCodec: CodecAPI = CodecAPI.nativeStringsAndBlob(codecs) - val input = ByteArray(request.body.asBytes().getOrElse(ByteString.empty).toArray) - val codec = nativeCodec - .compileCodec(inputSchema) - for { - metadataPartial <- inputMetadataDecoder - .decode(metadata) - .leftMap { e => - logger.info(e.getMessage()) - Smithy4PlayError( - "Error decoding Input Metadata", - additionalInformation = Some(e.getMessage()), - status = smithy4play.Status(Map.empty, 500) - ) - } - bodyPartial <- - nativeCodec - .decodeFromByteArrayPartial(codec, input.array) - .leftMap(e => - Smithy4PlayError( - s"expected: ${e.expected}", - smithy4play.Status(Map.empty, 400), - additionalInformation = Some(e.getMessage()) + )(implicit defaultContentType: ContentType): EitherT[Future, ContextRouteError, I] = + EitherT { + Future { + val codec = CodecDecider.requestDecoder(Seq(defaultContentType.value)) + codec + .fromSchema(inputSchema) + .decode({ + val body = request.body.asBytes().map(b => Blob(b.toByteBuffer)).getOrElse(Blob.empty) + PlayHttpRequest( + metadata = metadata, + body = body ) - ) - } yield metadataPartial.combine(bodyPartial) - } + }) + } + }.leftMap { + case error: PayloadError => + Smithy4PlayError( + error.filterMessage, + smithy4play.Status(Map.empty, 400), + contentType = defaultContentType.value + ) + case error: MetadataError => + Smithy4PlayError( + error.filterMessage, + smithy4play.Status(Map.empty, 400), + contentType = defaultContentType.value + ) + } private def getMetadata(pathParams: PathParams, request: RequestHeader): Metadata = Metadata( @@ -200,20 +140,25 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[ ) private def handleFailure(error: ContextRouteError): Result = - Results.Status(error.status.statusCode)(error.toJson).withHeaders(error.status.headers.toList: _*) - - private def handleSuccess(output: EndpointResult): Result = { - val status = Results.Status(output.status.statusCode) - val outputHeadersWithoutContentType = output.status.headers.-("content-type").toList + Results + .Status(error.status.statusCode)(error.parse) + .withHeaders(error.status.headers.toList: _*) + .as(error.contentType) + + private def handleSuccess(output: HttpResponse[Blob])(implicit defaultContentType: ContentType): Result = { + val status = Results.Status(output.statusCode) + val contentTypeKey = CaseInsensitive("content-type") + val outputHeadersWithoutContentType = + output.headers.-(contentTypeKey).toList.map(h => (h._1.toString, h._2.head)) val contentType = - output.status.headers.getOrElse("content-type", "application/json") - - output.body match { - case Some(value) => - status(value) - .as(contentType) - .withHeaders(outputHeadersWithoutContentType: _*) - case None => status("").withHeaders(outputHeadersWithoutContentType: _*) + output.headers.getOrElse(contentTypeKey, Seq(defaultContentType.value)) + + if (!output.body.isEmpty) { + status(output.body.toArray) + .as(contentType.head) + .withHeaders(outputHeadersWithoutContentType: _*) + } else { + status("").withHeaders(outputHeadersWithoutContentType: _*) } } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala index 2a785540..d4a0b588 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala @@ -1,14 +1,15 @@ package de.innfactory.smithy4play -import cats.data.{ EitherT, Kleisli } import cats.implicits.toTraverseOps import de.innfactory.smithy4play.middleware.MiddlewareBase import play.api.mvc.{ AbstractController, ControllerComponents, Handler, RequestHeader } import play.api.routing.Router.Routes -import smithy4s.HintMask +import smithy4s.codecs.{ BlobEncoder, PayloadDecoder, PayloadEncoder } import smithy4s.http.{ HttpEndpoint, PathSegment } -import smithy4s.internals.InputOutput +import smithy4s.json.{ Json, JsonPayloadCodecCompiler } import smithy4s.kinds.{ FunctorAlgebra, Kind1, PolyFunction5 } +import smithy4s.schema.CachedSchemaCompiler +import smithy4s.xml.Xml import scala.concurrent.ExecutionContext @@ -25,7 +26,7 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[ val interpreter: PolyFunction5[service.Operation, Kind1[F]#toKind5] = service.toPolyFunction[Kind1[F]#toKind5](impl) val endpoints: Seq[service.Endpoint[_, _, _, _, _]] = service.endpoints val httpEndpoints: Seq[Either[HttpEndpoint.HttpEndpointError, HttpEndpoint[_]]] = - endpoints.map(HttpEndpoint.cast(_)) + endpoints.map(ep => HttpEndpoint.cast(ep.schema)) new PartialFunction[RequestHeader, Handler] { override def isDefinedAt(x: RequestHeader): Boolean = { @@ -36,7 +37,7 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[ override def apply(v1: RequestHeader): Handler = { logger.debug("[SmithyPlayRouter] calling apply on: " + service.id.name) for { - zippedEndpoints <- endpoints.map(ep => HttpEndpoint.cast(ep).map((ep, _))).sequence + zippedEndpoints <- endpoints.map(ep => HttpEndpoint.cast(ep.schema).map((ep, _))).sequence endpointAndHttpEndpoint <- zippedEndpoints .find(ep => checkIfRequestHeaderMatchesEndpoint(v1, ep._2)) @@ -47,8 +48,7 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[ service, interpreter, middlewares, - endpointAndHttpEndpoint._1, - smithy4s.http.json.codecs(alloy.SimpleRestJson.protocol.hintMask ++ HintMask(InputOutput)) + endpointAndHttpEndpoint._1 ).handler(v1) } match { case Right(value) => value @@ -61,7 +61,7 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[ private def checkIfRequestHeaderMatchesEndpoint( x: RequestHeader, ep: HttpEndpoint[_] - ) = { + ): Boolean = { ep.path.map { case PathSegment.StaticSegment(value) => value case PathSegment.LabelSegment(value) => value diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/GenericAPIClient.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/GenericAPIClient.scala index 783d76ce..c9e98dca 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/GenericAPIClient.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/GenericAPIClient.scala @@ -3,7 +3,9 @@ package de.innfactory.smithy4play.client import cats.data.Kleisli import de.innfactory.smithy4play.{ ClientResponse, RunnableClientRequest } import smithy4s.Service +import smithy4s.http.CaseInsensitive import smithy4s.kinds.{ FunctorK, FunctorK5, Kind1, PolyFunction5 } + import scala.concurrent.ExecutionContext private class GenericAPIClient[Alg[_[_, _, _, _, _]]]( @@ -18,7 +20,9 @@ private class GenericAPIClient[Alg[_[_, _, _, _, _]]]( private def transformer(): Alg[Kind1[RunnableClientRequest]#toKind5] = smithyPlayClient.service.fromPolyFunction(this.opToResponse()) - private def transformer(additionalHeaders: Option[Map[String, Seq[String]]]): Alg[Kind1[ClientResponse]#toKind5] = + private def transformer( + additionalHeaders: Option[Map[CaseInsensitive, Seq[String]]] + ): Alg[Kind1[ClientResponse]#toKind5] = smithyPlayClient.service.fromPolyFunction(this.opToResponse(additionalHeaders)) /* uses the SmithyPlayClient to transform a Operation to a ClientResponse */ @@ -26,14 +30,14 @@ private class GenericAPIClient[Alg[_[_, _, _, _, _]]]( new PolyFunction5[smithyPlayClient.service.Operation, Kind1[RunnableClientRequest]#toKind5] { override def apply[I, E, O, SI, SO]( fa: smithyPlayClient.service.Operation[I, E, O, SI, SO] - ): Kleisli[ClientResponse, Option[Map[String, Seq[String]]], O] = - Kleisli[ClientResponse, Option[Map[String, Seq[String]]], O] { additionalHeaders => + ): Kleisli[ClientResponse, Option[Map[CaseInsensitive, Seq[String]]], O] = + Kleisli[ClientResponse, Option[Map[CaseInsensitive, Seq[String]]], O] { additionalHeaders => smithyPlayClient.send(fa, additionalHeaders) } } private def opToResponse( - additionalHeaders: Option[Map[String, Seq[String]]] + additionalHeaders: Option[Map[CaseInsensitive, Seq[String]]] ): PolyFunction5[smithyPlayClient.service.Operation, Kind1[ClientResponse]#toKind5] = new PolyFunction5[smithyPlayClient.service.Operation, Kind1[ClientResponse]#toKind5] { override def apply[I, E, O, SI, SO]( @@ -49,7 +53,7 @@ object GenericAPIClient { def withClientAndHeaders( client: RequestClient, - additionalHeaders: Option[Map[String, Seq[String]]], + additionalHeaders: Option[Map[CaseInsensitive, Seq[String]]], additionalSuccessCodes: List[Int] = List.empty )(implicit ec: ExecutionContext): Alg[Kind1[ClientResponse]#toKind5] = apply(service, additionalHeaders, additionalSuccessCodes, client) @@ -71,7 +75,7 @@ object GenericAPIClient { def apply[Alg[_[_, _, _, _, _]]]( serviceI: Service[Alg], - additionalHeaders: Option[Map[String, Seq[String]]], + additionalHeaders: Option[Map[CaseInsensitive, Seq[String]]], additionalSuccessCodes: List[Int], client: RequestClient )(implicit ec: ExecutionContext): Alg[Kind1[ClientResponse]#toKind5] = diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala index 569848ff..45919432 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/RequestClient.scala @@ -1,16 +1,17 @@ package de.innfactory.smithy4play.client +import de.innfactory.smithy4play.EndpointRequest import play.api.mvc.Headers +import smithy4s.Blob +import smithy4s.http.{ CaseInsensitive, HttpResponse } 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] + headers: Map[CaseInsensitive, Seq[String]], + request: EndpointRequest + ): Future[HttpResponse[Blob]] } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala index ecfae1b5..6311dd95 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClient.scala @@ -1,9 +1,11 @@ package de.innfactory.smithy4play.client +import cats.implicits.toBifunctorOps import de.innfactory.smithy4play.ClientResponse -import smithy4s.http.HttpEndpoint +import smithy4s.Blob +import smithy4s.http.{ CaseInsensitive, HttpEndpoint } -import scala.concurrent.ExecutionContext +import scala.concurrent.{ ExecutionContext, Future } class SmithyPlayClient[Alg[_[_, _, _, _, _]], F[_]]( baseUri: String, @@ -14,12 +16,12 @@ class SmithyPlayClient[Alg[_[_, _, _, _, _]], F[_]]( def send[I, E, O, SI, SO]( op: service.Operation[I, E, O, SI, SO], - additionalHeaders: Option[Map[String, Seq[String]]] + additionalHeaders: Option[Map[CaseInsensitive, Seq[String]]] ): ClientResponse[O] = { - val (input, endpoint) = service.endpoint(op) + val endpoint = service.endpoint(op) HttpEndpoint - .cast(endpoint) + .cast(endpoint.schema) .map(httpEndpoint => new SmithyPlayClientEndpoint( endpoint = endpoint, @@ -27,7 +29,8 @@ class SmithyPlayClient[Alg[_[_, _, _, _, _]], F[_]]( additionalHeaders = additionalHeaders, additionalSuccessCodes = additionalSuccessCodes, httpEndpoint = httpEndpoint, - input = input, + input = service.input(op), + serviceHints = service.hints, client = client ).send() ) diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala index 06368ee1..fb632569 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala @@ -1,88 +1,92 @@ package de.innfactory package smithy4play package client -import smithy4s.{ Endpoint, Schema } -import smithy4s.http.{ CaseInsensitive, CodecAPI, HttpEndpoint, Metadata, MetadataError, PayloadError } + +import alloy.SimpleRestJson +import aws.protocols.RestXml import cats.implicits._ +import play.api.http.MimeTypes +import smithy4s.codecs.PayloadError +import smithy4s.http._ +import smithy4s.{ Blob, Endpoint, Hints, Schema } import scala.concurrent.{ ExecutionContext, Future } private[smithy4play] class SmithyPlayClientEndpoint[Op[_, _, _, _, _], I, E, O, SI, SO]( endpoint: Endpoint[Op, I, E, O, SI, SO], + serviceHints: Hints, baseUri: String, - additionalHeaders: Option[Map[String, Seq[String]]], - additionalSuccessCodes: List[Int] = List.empty, + additionalHeaders: Option[Map[CaseInsensitive, Seq[String]]], + additionalSuccessCodes: List[Int], httpEndpoint: HttpEndpoint[I], input: I, client: RequestClient )(implicit executionContext: ExecutionContext) { - private val inputSchema: Schema[I] = endpoint.input - private val outputSchema: Schema[O] = endpoint.output + private implicit val inputSchema: Schema[I] = endpoint.input + private implicit 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 + private val serviceContentType: String = serviceHints.toMimeType + private val inputMetadataEncoder = + Metadata.Encoder.fromSchema(HttpRestSchema.OnlyMetadata(inputSchema).schema) + private val contentTypeKey = CaseInsensitive("content-type") def send( ): ClientResponse[O] = { - val metadata = inputMetadataEncoder.encode(input) - val path = buildPath(metadata) - val headers = metadata.headers.map(x => (x._1.toString.toLowerCase, x._2)) - val headersWithAuth = if (additionalHeaders.isDefined) headers.combine(additionalHeaders.get) else headers - val code = httpEndpoint.code - val codecApi: CodecAPI = CodecUtils.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) + val metadata = inputMetadataEncoder.encode(input) + val path = buildPath(metadata) + val headers = metadata.headers + val contentTypeOpt = headers.get(contentTypeKey) + val contentType = contentTypeOpt.getOrElse(Seq(serviceContentType)) + val headersWithAuth = if (additionalHeaders.isDefined) headers.combine(additionalHeaders.get) else headers + val code = httpEndpoint.code + val response = + client.send( + httpEndpoint.method.toString, + path, + headersWithAuth.updated(contentTypeKey, contentType), + writeInputToBlob(input, contentType) + ) decodeResponse(response, code) } + private def writeInputToBlob(input: I, contentType: Seq[String]): EndpointRequest = { + val codecs = CodecDecider.requestEncoder(contentType) + codecs.fromSchema(inputSchema).write(PlayHttpRequest(Blob.empty, Metadata.empty), input) + } + private def decodeResponse( - response: Future[SmithyClientResponse], + response: Future[HttpResponse[Blob]], expectedCode: Int ): ClientResponse[O] = for { - res <- response - metadata = Metadata(headers = res.headers.map(headers => (CaseInsensitive(headers._1), headers._2))) - output <- if ((additionalSuccessCodes :+ expectedCode).contains(res.statusCode)) - handleSuccess(metadata, res) - else handleError(res) + res <- response + output <- if ((additionalSuccessCodes :+ expectedCode).contains(res.statusCode)) { + handleSuccess(res) + } else handleError(res) } yield output - def handleSuccess(metadata: Metadata, response: SmithyClientResponse) = { - val headers = response.headers.map(x => (x._1.toLowerCase, x._2)) - val output = outputMetadataDecoder.total match { - case Some(totalDecoder) => - totalDecoder.decode(metadata) - case None => - for { - metadataPartial <- outputMetadataDecoder.decode(metadata) - codecApi = CodecUtils.extractCodec(headers) - bodyPartial <- - codecApi.decodeFromByteArrayPartial(codecApi.compileCodec(outputSchema), response.body.get) - output <- metadataPartial.combineCatch(bodyPartial) - } yield output + def handleSuccess(response: HttpResponse[Blob]): ClientResponse[O] = + Future { + val headers = response.headers.map(x => (x._1, x._2)) + val contentType = headers.getOrElse(contentTypeKey, Seq(serviceContentType)) + val codec = CodecDecider.httpResponseDecoder(contentType) + + codec + .fromSchema(outputSchema) + .decode(response) + .map(o => HttpResponse(response.statusCode, headers, o)) + .leftMap { + case error: PayloadError => + SmithyPlayClientEndpointErrorResponse(error.expected.getBytes(), response.statusCode) + case error: MetadataError => + SmithyPlayClientEndpointErrorResponse(error.getMessage().getBytes(), response.statusCode) + } } - Future( - output.map(o => SmithyPlayClientEndpointResponse(Some(o), headers, response.statusCode)).left.map { - case error: PayloadError => - SmithyPlayClientEndpointErrorResponse(error.expected.getBytes, response.statusCode) - case error: MetadataError => - SmithyPlayClientEndpointErrorResponse(error.getMessage().getBytes(), response.statusCode) - } - ) - } - def handleError(response: SmithyClientResponse) = Future( + private def handleError(response: HttpResponse[Blob]): ClientResponse[O] = Future( Left { SmithyPlayClientEndpointErrorResponse( - response.body.getOrElse(Array.emptyByteArray), + response.body.toArray, response.statusCode ) } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointResponse.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointResponse.scala deleted file mode 100644 index 1989a172..00000000 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpointResponse.scala +++ /dev/null @@ -1,9 +0,0 @@ -package de.innfactory.smithy4play.client - -import de.innfactory.smithy4play.Showable - -case class SmithyPlayClientEndpointResponse[O]( - body: Option[O], - headers: Map[String, Seq[String]], - statusCode: Int -) extends Showable diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala index 2bd04165..c90c2f5d 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayTestUtils.scala @@ -2,6 +2,7 @@ package de.innfactory.smithy4play.client import de.innfactory.smithy4play.{ logger, ClientResponse } import play.api.libs.json.{ Json, Reads } +import smithy4s.http.HttpResponse import scala.concurrent.duration.{ Duration, DurationInt } import scala.concurrent.{ Await, ExecutionContext } @@ -12,10 +13,13 @@ object SmithyPlayTestUtils { def awaitRight(implicit ec: ExecutionContext, timeout: Duration = 5.seconds - ): SmithyPlayClientEndpointResponse[O] = + ): HttpResponse[O] = Await.result( response.map { res => - if (res.isLeft) logger.error(s"Expected Right, got Left: ${res.left.toOption.get.toString}") + if (res.isLeft) + logger.error( + s"Expected Right, got Left: ${res.left.toOption.get.toString} Error: ${res.left.toOption.get.error.toErrorString}" + ) res.toOption.get }, timeout @@ -23,8 +27,7 @@ object SmithyPlayTestUtils { def awaitLeft(implicit ec: ExecutionContext, - timeout: Duration = 5.seconds, - errorAsString: Boolean = true + timeout: Duration = 5.seconds ): SmithyPlayClientEndpointErrorResponse = Await.result( response.map { res => diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/compliancetests/ComplianceClient.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/compliancetests/ComplianceClient.scala index 7119ee20..87c0377e 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/compliancetests/ComplianceClient.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/compliancetests/ComplianceClient.scala @@ -1,9 +1,9 @@ package de.innfactory.smithy4play.compliancetests import de.innfactory.smithy4play.ClientResponse -import de.innfactory.smithy4play.client.{ SmithyPlayClientEndpointErrorResponse, SmithyPlayClientEndpointResponse } +import de.innfactory.smithy4play.client.SmithyPlayClientEndpointErrorResponse import play.api.libs.json.Json -import smithy4s.http.HttpEndpoint +import smithy4s.http.{ HttpEndpoint, HttpResponse } import smithy4s.kinds.{ FunctorAlgebra, Kind1 } import smithy4s.{ Document, Endpoint, Service } import smithy.test._ @@ -37,12 +37,12 @@ class ComplianceClient[ } private def matchResponse[I, E, O, SE, SO]( - response: Either[SmithyPlayClientEndpointErrorResponse, SmithyPlayClientEndpointResponse[O]], + response: Either[SmithyPlayClientEndpointErrorResponse, HttpResponse[O]], endpoint: Endpoint[service.Operation, I, E, O, SE, SO], responseTestCase: Option[HttpResponseTestCase] ) = { - val httpEp = HttpEndpoint.cast(endpoint).toOption.get + val httpEp = HttpEndpoint.cast(endpoint.schema).toOption.get val responseStatusCode = response match { case Left(value) => value.statusCode case Right(value) => value.statusCode @@ -59,7 +59,7 @@ class ComplianceClient[ expectedCode = expectedStatusCode, receivedCode = responseStatusCode, expectedBody = expectedOutput, - receivedBody = response.toOption.flatMap(_.body), + receivedBody = response.toOption.map(_.body), expectedError = responseTestCase match { case Some(value) => value.body.getOrElse("") case None => "" diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/MiddlewareBase.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/MiddlewareBase.scala index 57441b7f..43767590 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/MiddlewareBase.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/MiddlewareBase.scala @@ -1,8 +1,10 @@ package de.innfactory.smithy4play.middleware import cats.data.Kleisli -import de.innfactory.smithy4play.{ EndpointResult, RouteResult, RoutingContext } +import de.innfactory.smithy4play.{ RouteResult, RoutingContext } import play.api.Logger +import smithy4s.Blob +import smithy4s.http.HttpResponse trait MiddlewareBase { @@ -10,14 +12,14 @@ trait MiddlewareBase { protected def logic( r: RoutingContext, - next: RoutingContext => RouteResult[EndpointResult] - ): RouteResult[EndpointResult] + next: RoutingContext => RouteResult[HttpResponse[Blob]] + ): RouteResult[HttpResponse[Blob]] protected def skipMiddleware(r: RoutingContext): Boolean = false def middleware( - f: RoutingContext => RouteResult[EndpointResult] - ): Kleisli[RouteResult, RoutingContext, EndpointResult] = + f: RoutingContext => RouteResult[HttpResponse[Blob]] + ): Kleisli[RouteResult, RoutingContext, HttpResponse[Blob]] = Kleisli { r => if (skipMiddleware(r)) f(r) else logic(r, f) diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/ValidateAuthMiddleware.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/ValidateAuthMiddleware.scala index b95f6802..73f1bc6e 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/ValidateAuthMiddleware.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/middleware/ValidateAuthMiddleware.scala @@ -2,8 +2,11 @@ package de.innfactory.smithy4play.middleware import cats.data.EitherT import de.innfactory.smithy4play -import de.innfactory.smithy4play.{ EndpointResult, RouteResult, RoutingContext, Smithy4PlayError } +import de.innfactory.smithy4play.{ RouteResult, RoutingContext, Smithy4PlayError } +import smithy.api import smithy.api.{ Auth, HttpBearerAuth } +import smithy4s.Blob +import smithy4s.http.HttpResponse import javax.inject.{ Inject, Singleton } import scala.concurrent.{ ExecutionContext, Future } @@ -14,17 +17,24 @@ class ValidateAuthMiddleware @Inject() (implicit ) extends MiddlewareBase { override protected def skipMiddleware(r: RoutingContext): Boolean = { - val serviceAuthHints = r.serviceHints.get(HttpBearerAuth.tagInstance).map(_ => Auth(Set(HttpBearerAuth.id.show))) + val serviceAuthHints: Option[api.Auth.Type] = + r.serviceHints + .get(HttpBearerAuth.tagInstance) + .map(_ => + Auth(Set(smithy.api.AuthTraitReference(smithy4s.ShapeId(namespace = "smithy.api", name = "httpBearerAuth")))) + ) for { authSet <- r.endpointHints.get(Auth.tag) orElse serviceAuthHints - _ <- authSet.value.find(_.value == HttpBearerAuth.id.show) + _ <- authSet.value.find(_.value.name == HttpBearerAuth.id.name) } yield r.headers.contains("Authorization") }.getOrElse(true) override def logic( r: RoutingContext, - next: RoutingContext => RouteResult[EndpointResult] - ): RouteResult[EndpointResult] = - EitherT.leftT[Future, EndpointResult](Smithy4PlayError("Unauthorized", status = smithy4play.Status(Map.empty, 401))) + next: RoutingContext => RouteResult[HttpResponse[Blob]] + ): RouteResult[HttpResponse[Blob]] = + EitherT.leftT[Future, HttpResponse[Blob]]( + Smithy4PlayError("Unauthorized", status = smithy4play.Status(Map.empty, 401), contentType = "application/json") + ) } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala index 99c6eb7a..921c6f32 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala @@ -1,26 +1,40 @@ package de.innfactory +import alloy.SimpleRestJson +import aws.protocols.RestXml import cats.data.{ EitherT, Kleisli } -import de.innfactory.smithy4play.client.{ SmithyPlayClientEndpointErrorResponse, SmithyPlayClientEndpointResponse } +import de.innfactory.smithy4play.client.SmithyPlayClientEndpointErrorResponse import org.slf4j import play.api.Logger -import play.api.libs.json.{ JsValue, Json } +import play.api.http.MimeTypes +import play.api.libs.json.{ JsValue, Json, OFormat } import play.api.mvc.{ Headers, RequestHeader } -import smithy4s.http.{ CaseInsensitive, HttpEndpoint } +import smithy4s.{ Blob, Hints } +import smithy4s.http.{ CaseInsensitive, HttpEndpoint, HttpResponse, Metadata } import scala.annotation.{ compileTimeOnly, StaticAnnotation } import scala.concurrent.Future import scala.language.experimental.macros +import scala.util.matching.Regex +import scala.xml.Elem package object smithy4play { trait ContextRouteError extends StatusResult[ContextRouteError] { + def contentType: String = "application/json" def message: String + def toXml: Elem = {message} + def parse: String = contentType match { + case "application/json" => toJson.toString() + case "application/xml" => toXml.toString() + } def toJson: JsValue } - type ClientResponse[O] = Future[Either[SmithyPlayClientEndpointErrorResponse, SmithyPlayClientEndpointResponse[O]]] - type RunnableClientRequest[O] = Kleisli[ClientResponse, Option[Map[String, Seq[String]]], O] + case class ContentType(value: String) + + type ClientResponse[O] = Future[Either[SmithyPlayClientEndpointErrorResponse, HttpResponse[O]]] + type RunnableClientRequest[O] = Kleisli[ClientResponse, Option[Map[CaseInsensitive, Seq[String]]], O] type RouteResult[O] = EitherT[Future, ContextRouteError, O] type ContextRoute[O] = Kleisli[RouteResult, RoutingContext, O] @@ -31,21 +45,46 @@ package object smithy4play { case class Status(headers: Map[String, String], statusCode: Int) object Status { - implicit val format = Json.format[Status] + implicit val format: OFormat[Status] = Json.format[Status] } - case class EndpointResult(body: Option[Array[Byte]], status: Status) extends StatusResult[EndpointResult] { - override def addHeaders(headers: Map[String, String]): EndpointResult = this.copy( - status = status.copy( - headers = status.headers ++ headers + type EndpointRequest = PlayHttpRequest[Blob] + case class PlayHttpRequest[Body](body: Body, metadata: Metadata) { + def addHeaders(headers: Map[CaseInsensitive, Seq[String]]): PlayHttpRequest[Body] = this.copy( + metadata = metadata.copy( + headers = metadata.headers ++ headers ) ) } + implicit class EnhancedHints(hints: Hints) { + def toMimeType: String = + (hints.get(RestXml.getTag), hints.get(SimpleRestJson.getTag)) match { + case (Some(_), None) => MimeTypes.XML + case _ => MimeTypes.JSON + } + } + + implicit class EnhancedThrowable(throwable: Throwable) { + private val regex1: Regex = """(?s), offset: (?:0x)?[0-9a-fA-F]+, buf:.*""".r + private val regex2: Regex = """(.*), offset: .*, buf:.* (\(path:.*\))""".r + def filterMessage: String = + regex2.replaceAllIn( + throwable.getMessage.filter(_ >= ' '), + m => + m.matched match { + case regex2(initialMessage, endMessage) => s"$initialMessage: $endMessage" + case msg => regex1.replaceAllIn(msg, "") + } + ) + + } + private[smithy4play] case class Smithy4PlayError( message: String, status: Status, - additionalInformation: Option[String] = None + additionalInformation: Option[String] = None, + override val contentType: String ) extends ContextRouteError { override def toJson: JsValue = Json.toJson(this)(Smithy4PlayError.format) override def addHeaders(headers: Map[String, String]): Smithy4PlayError = this.copy( diff --git a/smithy4playTest/.gitignore b/smithy4playTest/.gitignore index 716038bf..90223970 100644 --- a/smithy4playTest/.gitignore +++ b/smithy4playTest/.gitignore @@ -1 +1 @@ -*/testDefinitions/* \ No newline at end of file +*/specs/* \ No newline at end of file diff --git a/smithy4playTest/app/controller/TestController.scala b/smithy4playTest/app/controller/TestController.scala index ee60b420..20ec6951 100644 --- a/smithy4playTest/app/controller/TestController.scala +++ b/smithy4playTest/app/controller/TestController.scala @@ -4,7 +4,7 @@ import cats.data.{ EitherT, Kleisli } import controller.models.TestError import de.innfactory.smithy4play.{ AutoRouting, ContextRoute, ContextRouteError } import play.api.mvc.ControllerComponents -import smithy4s.ByteArray +import smithy4s.Blob import testDefinitions.test._ import javax.inject.{ Inject, Singleton } @@ -48,7 +48,7 @@ class TestController @Inject() (implicit } - override def testWithBlob(body: ByteArray, contentType: String): ContextRoute[BlobResponse] = Kleisli { rc => + override def testWithBlob(body: Blob, contentType: String): ContextRoute[BlobResponse] = Kleisli { rc => EitherT.rightT[Future, ContextRouteError](BlobResponse(body, "image/png")) } diff --git a/smithy4playTest/app/controller/XmlController.scala b/smithy4playTest/app/controller/XmlController.scala new file mode 100644 index 00000000..964ec18e --- /dev/null +++ b/smithy4playTest/app/controller/XmlController.scala @@ -0,0 +1,33 @@ +package controller + +import cats.data.{ EitherT, Kleisli } +import cats.implicits.catsSyntaxEitherId +import de.innfactory.smithy4play.{ AutoRouting, ContextRoute, ContextRouteError } +import play.api.mvc.ControllerComponents +import testDefinitions.test.{ XmlControllerDef, XmlTestInputBody, XmlTestOutput, XmlTestWithInputAndOutputOutput } + +import javax.inject.{ Inject, Singleton } +import scala.concurrent.{ ExecutionContext, Future } + +@Singleton +@AutoRouting +class XmlController @Inject() (implicit + cc: ControllerComponents, + executionContext: ExecutionContext +) extends XmlControllerDef[ContextRoute] { + + override def xmlTestWithInputAndOutput( + xmlTest: String, + body: XmlTestInputBody + ): ContextRoute[XmlTestWithInputAndOutputOutput] = + Kleisli { _ => + EitherT( + Future( + XmlTestWithInputAndOutputOutput( + XmlTestOutput(body.serverzeit, body.requiredTest + xmlTest, body.requiredInt.map(i => i * i)) + ) + .asRight[ContextRouteError] + ) + ) + } +} diff --git a/smithy4playTest/app/controller/middlewares/ChangeStatusCodeMiddleware.scala b/smithy4playTest/app/controller/middlewares/ChangeStatusCodeMiddleware.scala index f92daa0c..77b19788 100644 --- a/smithy4playTest/app/controller/middlewares/ChangeStatusCodeMiddleware.scala +++ b/smithy4playTest/app/controller/middlewares/ChangeStatusCodeMiddleware.scala @@ -1,7 +1,9 @@ package controller.middlewares import de.innfactory.smithy4play.middleware.MiddlewareBase -import de.innfactory.smithy4play.{ EndpointResult, RouteResult, RoutingContext, Status } +import de.innfactory.smithy4play.{ RouteResult, RoutingContext } +import smithy4s.Blob +import smithy4s.http.HttpResponse import testDefinitions.test.ChangeStatusCode import javax.inject.Inject @@ -14,11 +16,11 @@ class ChangeStatusCodeMiddleware @Inject() (implicit executionContext: Execution override protected def logic( r: RoutingContext, - next: RoutingContext => RouteResult[EndpointResult] - ): RouteResult[EndpointResult] = { + next: RoutingContext => RouteResult[HttpResponse[Blob]] + ): RouteResult[HttpResponse[Blob]] = { val res = next(r) res.map { r => - r.copy(status = Status(r.status.headers, 269)) + r.copy(statusCode = 269) } } diff --git a/smithy4playTest/app/controller/middlewares/DisableAbleMiddleware.scala b/smithy4playTest/app/controller/middlewares/DisableAbleMiddleware.scala index a7591e27..ed6340a0 100644 --- a/smithy4playTest/app/controller/middlewares/DisableAbleMiddleware.scala +++ b/smithy4playTest/app/controller/middlewares/DisableAbleMiddleware.scala @@ -1,7 +1,9 @@ package controller.middlewares import de.innfactory.smithy4play.middleware.MiddlewareBase -import de.innfactory.smithy4play.{ EndpointResult, RouteResult, RoutingContext } +import de.innfactory.smithy4play.{ RouteResult, RoutingContext } +import smithy4s.Blob +import smithy4s.http.HttpResponse import testDefinitions.test.DisableTestMiddleware import javax.inject.{ Inject, Singleton } @@ -15,14 +17,14 @@ class DisableAbleMiddleware @Inject() (implicit executionContext: ExecutionConte override protected def logic( r: RoutingContext, - next: RoutingContext => RouteResult[EndpointResult] - ): RouteResult[EndpointResult] = { + next: RoutingContext => RouteResult[HttpResponse[Blob]] + ): RouteResult[HttpResponse[Blob]] = { logger.info("[DisableAbleMiddleware.logic1]") val r1 = r.copy(attributes = r.attributes + ("Not" -> "Disabled")) val res = next(r1) logger.info("[DisableAbleMiddleware.logic2]") res.map { r => - logger.info(s"[DisableAbleMiddleware.logic3] ${r.status.headers.toString()}") + logger.info(s"[DisableAbleMiddleware.logic3] ${r.headers.toString()}") r } } diff --git a/smithy4playTest/app/controller/middlewares/TestMiddlewareImpl.scala b/smithy4playTest/app/controller/middlewares/TestMiddlewareImpl.scala index a9d9917e..bcd62b10 100644 --- a/smithy4playTest/app/controller/middlewares/TestMiddlewareImpl.scala +++ b/smithy4playTest/app/controller/middlewares/TestMiddlewareImpl.scala @@ -1,7 +1,9 @@ package controller.middlewares import de.innfactory.smithy4play.middleware.MiddlewareBase -import de.innfactory.smithy4play.{ EndpointResult, RouteResult, RoutingContext, Status } +import de.innfactory.smithy4play.{ RouteResult, RoutingContext } +import smithy4s.Blob +import smithy4s.http.{ CaseInsensitive, HttpResponse } import javax.inject.Inject import scala.concurrent.ExecutionContext @@ -10,15 +12,15 @@ class TestMiddlewareImpl @Inject() (implicit executionContext: ExecutionContext) override protected def logic( r: RoutingContext, - next: RoutingContext => RouteResult[EndpointResult] - ): RouteResult[EndpointResult] = { + next: RoutingContext => RouteResult[HttpResponse[Blob]] + ): RouteResult[HttpResponse[Blob]] = { logger.info("[TestMiddleware.logic1]") val r1 = r.copy(attributes = r.attributes + ("Test" -> "Test")) val res = next(r1) logger.info("[TestMiddleware.logic2]") res.map { r => logger.info("[TestMiddleware.logic3]") - r.addHeaders(Map("EndpointResultTest" -> "Test123")) + r.addHeaders(Map(CaseInsensitive("EndpointResultTest") -> Seq("Test123"))) } } diff --git a/smithy4playTest/app/controller/models/TestError.scala b/smithy4playTest/app/controller/models/TestError.scala index d4c454ec..6c7ee587 100644 --- a/smithy4playTest/app/controller/models/TestError.scala +++ b/smithy4playTest/app/controller/models/TestError.scala @@ -1,7 +1,7 @@ package controller.models import de.innfactory.smithy4play.{ ContextRouteError, Status } -import play.api.libs.json.{ JsValue, Json } +import play.api.libs.json.{ JsValue, Json, OFormat } case class TestError( message: String, @@ -16,5 +16,5 @@ case class TestError( } object TestError { - implicit val format = Json.format[TestError] + implicit val format: OFormat[TestError] = Json.format[TestError] } diff --git a/smithy4playTest/test/TestControllerTest.scala b/smithy4playTest/test/TestControllerTest.scala index bd857297..7bdb8c9b 100644 --- a/smithy4playTest/test/TestControllerTest.scala +++ b/smithy4playTest/test/TestControllerTest.scala @@ -1,57 +1,32 @@ import controller.models.TestError -import de.innfactory.smithy4play.CodecUtils import de.innfactory.smithy4play.client.GenericAPIClient.EnhancedGenericAPIClient -import de.innfactory.smithy4play.client.{ RequestClient, SmithyClientResponse } import de.innfactory.smithy4play.client.SmithyPlayTestUtils._ import de.innfactory.smithy4play.compliancetests.ComplianceClient -import models.TestJson -import org.scalatestplus.play.{ BaseOneAppPerSuite, FakeApplicationFactory, PlaySpec } +import models.NodeImplicits.NodeEnhancer +import models.{ TestBase, TestJson } +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime 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.mvc.Result import play.api.test.FakeRequest import play.api.test.Helpers._ -import testDefinitions.test.{ SimpleTestResponse, TestControllerServiceGen, TestRequestBody } -import smithy4s.ByteArray +import smithy4s.Blob +import smithy4s.http.CaseInsensitive +import testDefinitions.test.{ + SimpleTestResponse, + TestControllerServiceGen, + TestRequestBody, + TestResponseBody, + TestWithOutputResponse +} import java.io.File import java.nio.file.Files import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -import scala.concurrent.duration.DurationInt - -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) - } - } + +class TestControllerTest extends TestBase { val genericClient = TestControllerServiceGen.withClientAndHeaders(FakeRequestClient, None, List(269)) @@ -86,7 +61,7 @@ class TestControllerTest extends PlaySpec with BaseOneAppPerSuite with FakeAppli val body = TestRequestBody("thisIsARequestBody") val result = genericClient.testWithOutput(pathParam, testQuery, testHeader, body).awaitRight - val responseBody = result.body.get + val responseBody = result.body result.statusCode mustBe 200 responseBody.body.testQuery mustBe testQuery responseBody.body.pathParam mustBe pathParam @@ -94,7 +69,7 @@ class TestControllerTest extends PlaySpec with BaseOneAppPerSuite with FakeAppli responseBody.body.testHeader mustBe testHeader } - "route to Test Endpoint but should return error because required header is not set" in { + "route to Test Endpoint with Query Parameter, Path Parameter and Body with fake request" in { val pathParam = "thisIsAPathParam" val testQuery = "thisIsATestQuery" val testHeader = "thisIsATestHeader" @@ -104,29 +79,74 @@ class TestControllerTest extends PlaySpec with BaseOneAppPerSuite with FakeAppli route( app, FakeRequest("POST", s"/test/$pathParam?testQuery=$testQuery") - .withHeaders(("Wrong-Header", testHeader)) + .withHeaders(("Test-Header", testHeader)) .withBody(Json.toJson(body)) ).get - status(future) mustBe 500 + implicit val formatBody = Json.format[TestResponseBody] + val responseBody = contentAsJson(future).as[TestResponseBody] + status(future) mustBe 200 + responseBody.testQuery mustBe testQuery + responseBody.pathParam mustBe pathParam + responseBody.bodyMessage mustBe body.message + responseBody.testHeader mustBe testHeader } - "route to Query Endpoint but should return error because query is not set" in { + "route to Test Endpoint with Query Parameter, Path Parameter and Body with fake request and xml protocol" in { + val pathParam = "thisIsAPathParam" + val testQuery = "thisIsATestQuery" + val testHeader = "thisIsATestHeader" + val testBody = "thisIsARequestBody" + val xml = {testBody} + val future: Future[Result] = + route( + app, + FakeRequest("POST", s"/test/$pathParam?testQuery=$testQuery") + .withHeaders(("Test-Header", testHeader)) + .withXmlBody(xml) + ).get + status(future) mustBe 200 + val xmlRes = scala.xml.XML.loadString(contentAsString(future)) + xmlRes.normalize mustBe + {testHeader} + {pathParam} + {testQuery} + {testBody} + .normalize + } + + "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 400 + } + + "route to Query Endpoint but should return error because query is not set" in { + val testQuery = "thisIsATestQuery" + val future: Future[Result] = route( app, FakeRequest("GET", s"/query?WrongQuery=$testQuery") ).get - status(future) mustBe 500 + status(future) mustBe 400 } "route to Health Endpoint" in { val result = genericClient.health().awaitRight - result.headers.contains("endpointresulttest") mustBe true + result.headers.contains(CaseInsensitive("endpointresulttest")) mustBe true result.statusCode mustBe 200 } @@ -140,11 +160,11 @@ class TestControllerTest extends PlaySpec with BaseOneAppPerSuite with FakeAppli "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 = genericClient.testWithBlob(pngAsBytes, "image/png").awaitRight + val pngAsBytes = Blob(Files.readAllBytes(file.toPath)) + val result = genericClient.testWithBlob(pngAsBytes, "image/png").awaitRight(global, 5.hours) result.statusCode mustBe 200 - pngAsBytes mustBe result.body.get.body + pngAsBytes mustBe result.body.body } "route to Auth Test" in { @@ -161,17 +181,18 @@ class TestControllerTest extends PlaySpec with BaseOneAppPerSuite with FakeAppli "manual writing json" in { - val writtenData = CodecUtils.writeEntityToJsonBytes(SimpleTestResponse(Some("Test")), SimpleTestResponse.schema) + val writtenData = smithy4s.json.Json.writeBlob(SimpleTestResponse(Some("Test"))) - val writtenJson = Json.parse(writtenData).as[TestJson] + val writtenJson = Json.parse(writtenData.toArray).as[TestJson] - val readData = CodecUtils.readFromJsonBytes( - Json.toBytes(Json.toJson(TestJson(Some("Test")))), - SimpleTestResponse.schema - ) + val readData = + smithy4s.json.Json.read(Blob(Json.toBytes(Json.toJson(TestJson(Some("Test"))))))(SimpleTestResponse.schema) writtenJson.message mustBe Some("Test") - readData.get.message mustBe Some("Test") + readData match { + case Right(value) => value.message mustBe Some("Test") + case _ => fail("should parse") + } } } } diff --git a/smithy4playTest/test/XmlControllerTest.scala b/smithy4playTest/test/XmlControllerTest.scala new file mode 100644 index 00000000..57b86ed2 --- /dev/null +++ b/smithy4playTest/test/XmlControllerTest.scala @@ -0,0 +1,144 @@ +import de.innfactory.smithy4play.CodecDecider +import de.innfactory.smithy4play.client.GenericAPIClient.EnhancedGenericAPIClient +import de.innfactory.smithy4play.client.SmithyPlayTestUtils._ +import models.NodeImplicits.NodeEnhancer +import models.TestBase +import play.api.Application +import play.api.inject.guice.GuiceApplicationBuilder +import play.api.libs.json.{ JsValue, Json, OFormat } +import play.api.test.FakeRequest +import play.api.test.Helpers._ +import smithy4s.Blob +import smithy4s.http.CaseInsensitive +import testDefinitions.test.{ XmlTestInputBody, XmlControllerDefGen, XmlTestOutput } + +import scala.xml._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.xml.{ Elem, Node, PrettyPrinter } + +class XmlControllerTest extends TestBase { + + val genericClient = XmlControllerDefGen.withClientAndHeaders(FakeRequestClient, None, List(269)) + + override def fakeApplication(): Application = + new GuiceApplicationBuilder().build() + + "controller.XmlController" must { + + "route to xml test endpoint" in { + val res = genericClient + .xmlTestWithInputAndOutput( + "Concat", + XmlTestInputBody("05.02.2024", "ThisGets", Some(10)) + ) + .awaitRight + res.body.body.requiredIntSquared mustBe Some(100) + res.headers.get(CaseInsensitive("content-type")) mustBe Some(List("application/xml")) + res.body.body.requiredTestStringConcat mustBe "ThisGetsConcat" + } + + "route to xml test endpoint with external client" in { + val concatVal1 = "ConcatThis" + val concatVal2 = "Test2" + val squareTest = 3 + val xml = + + {concatVal1} + {squareTest} + + val request = route( + app, + FakeRequest("POST", s"/xml/$concatVal2") + .withHeaders(("content-type", "application/xml")) + .withXmlBody( + xml + ) + ).get + status(request) mustBe 200 + + val result = scala.xml.XML.loadString(contentAsString(request)) + result.normalize mustBe + + {concatVal1 + concatVal2} + + {squareTest * squareTest} + .normalize + } + + "route to xml test endpoint with external client and throw error because of missing attribute" in { + val xml = + + + val request = route( + app, + FakeRequest("POST", s"/xml/Test2") + .withHeaders(("content-type", "application/xml")) + .withXmlBody( + xml + ) + ).get + status(request) mustBe 400 + val result = scala.xml.XML.loadString(contentAsString(request)) + result.normalize mustBe Expected a single node with text content (path: .XmlTestInputBody.requiredTest).normalize + } + + "route to xml test endpoint with external client and set json header but send xml" in { + val xml = + + + val request = route( + app, + FakeRequest("POST", s"/xml/Test2") + .withHeaders(("content-type", "application/json")) + .withXmlBody( + xml + ) + ).get + status(request) mustBe 400 + val result = contentAsJson(request) + result.toString() mustBe + "{\"message\":\"Expected JSON object: (path: .)\",\"status\":{\"headers\":{},\"statusCode\":400}," + + "\"contentType\":\"application/json\"}" + } + + "route to test endpoint with external client and set xml header but send " in { + implicit val format = Json.format[XmlTestInputBody] + + val request = route( + app, + FakeRequest("POST", s"/xml/Test2") + .withHeaders(("content-type", "application/xml")) + .withJsonBody( + Json.toJson(XmlTestInputBody("05.02.2024", "ThisShouldNotWork", Some(10))) + ) + ).get + status(request) mustBe 400 + val result = scala.xml.XML.loadString(contentAsString(request)) + result.normalize mustBe + {"Could not parse XML document: unexpected character '{' (path: .)"}.normalize + } + + "route to test endpoint with external client and json protocol" in { + implicit val formatI: OFormat[XmlTestInputBody] = Json.format[XmlTestInputBody] + implicit val formatO: OFormat[XmlTestOutput] = Json.format[XmlTestOutput] + val concatVal2 = "Test2" + val concatVal1 = "ConcatThis" + val squareTest = Some(15) + val date = "05.02.2024" + val request = route( + app, + FakeRequest("POST", s"/xml/$concatVal2") + .withHeaders(("content-type", "application/json")) + .withJsonBody( + Json.toJson(XmlTestInputBody(date, concatVal1, squareTest)) + ) + ).get + status(request) mustBe 200 + val result = contentAsJson(request).as[XmlTestOutput] + result.requiredTestStringConcat mustBe concatVal1 + concatVal2 + result.requiredIntSquared mustBe squareTest.map(s => s * s) + result.serverzeit mustBe date + } + + } +} diff --git a/smithy4playTest/test/models/NodeImplicits.scala b/smithy4playTest/test/models/NodeImplicits.scala new file mode 100644 index 00000000..bc04148d --- /dev/null +++ b/smithy4playTest/test/models/NodeImplicits.scala @@ -0,0 +1,25 @@ +package models + +import scala.xml.{ Elem, Node, Text } + +object NodeImplicits { + + implicit class NodeEnhancer(xml: Elem) { + def normalize: String = { + def normalizeText(text: String): String = text.trim + + def normalizeElem(node: Node): Node = node match { + case elem: Elem => + val normalizedChildren = elem.child.map { + case t: Text => Text(normalizeText(t.text)) + case other => normalizeElem(other) + } + elem.copy(child = normalizedChildren) + case other => other + } + + val normalizedXml = normalizeElem(xml) + normalizedXml.mkString + } + } +} diff --git a/smithy4playTest/test/models/TestBase.scala b/smithy4playTest/test/models/TestBase.scala new file mode 100644 index 00000000..949afc29 --- /dev/null +++ b/smithy4playTest/test/models/TestBase.scala @@ -0,0 +1,50 @@ +package models + +import de.innfactory.smithy4play.EndpointRequest +import de.innfactory.smithy4play.client.RequestClient +import org.scalatestplus.play.{BaseOneAppPerSuite, FakeApplicationFactory, PlaySpec} +import play.api.mvc.AnyContentAsEmpty +import play.api.test.FakeRequest +import play.api.test.Helpers.{route, writeableOf_AnyContentAsEmpty} +import smithy4s.Blob +import smithy4s.http.{CaseInsensitive, HttpResponse} + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import play.api.Play.materializer + + +trait TestBase extends PlaySpec with BaseOneAppPerSuite with FakeApplicationFactory { + + implicit object FakeRequestClient extends RequestClient { + override def send( + method: String, + path: String, + headers: Map[CaseInsensitive, Seq[String]], + result: EndpointRequest + ): Future[HttpResponse[Blob]] = { + val baseRequest: FakeRequest[AnyContentAsEmpty.type] = FakeRequest(method, path) + .withHeaders(headers.toList.flatMap(headers => headers._2.map(v => (headers._1.toString, v))): _*) + val res = + if (!result.body.isEmpty) route(app, baseRequest.withBody(result.body.toArray)).get + else + route( + app, + baseRequest + ).get + + for { + result <- res + headers = result.header.headers.map(v => (CaseInsensitive(v._1), Seq(v._2))) + body <- result.body.consumeData.map(_.toArrayUnsafe()) + bodyConsumed = if (result.body.isKnownEmpty) None else Some(body) + contentType = result.body.contentType + } yield HttpResponse( + result.header.status, + headers, + bodyConsumed.map(Blob(_)).getOrElse(Blob.empty) + ).withContentType(contentType.getOrElse("application/json")) + } + } + +} diff --git a/smithy4playTest/testSpecs/TestController.smithy b/smithy4playTest/testSpecs/TestController.smithy index e36d60f8..066ffd2c 100644 --- a/smithy4playTest/testSpecs/TestController.smithy +++ b/smithy4playTest/testSpecs/TestController.smithy @@ -8,7 +8,16 @@ use alloy#simpleRestJson @simpleRestJson service TestControllerService { version: "0.0.1", - operations: [Test, TestWithOutput, Health, TestWithBlob, TestWithQuery, TestThatReturnsError, TestAuth, TestWithOtherStatusCode] + operations: [ + Test + TestWithOutput + Health + TestWithBlob + TestWithQuery + TestThatReturnsError + TestAuth + TestWithOtherStatusCode + ] } @auth([]) @@ -24,11 +33,13 @@ operation TestWithBlob { input: BlobRequest, output: BlobResponse } + @auth([]) @readonly @http(method: "GET", uri: "/error", code: 200) operation TestThatReturnsError { } + @auth([]) @readonly @http(method: "GET", uri: "/query", code: 200) diff --git a/smithy4playTest/testSpecs/XmlController.smithy b/smithy4playTest/testSpecs/XmlController.smithy new file mode 100644 index 00000000..9daa33e2 --- /dev/null +++ b/smithy4playTest/testSpecs/XmlController.smithy @@ -0,0 +1,52 @@ +$version: "2" +namespace testDefinitions.test + +use aws.protocols#restXml + +@restXml +service XmlControllerDef { + version: "0.0.1", + operations: [ + XmlTestWithInputAndOutput + ] +} + +@http(method: "POST", uri: "/xml/{xmlTest}") +operation XmlTestWithInputAndOutput { + input: XmlTestInput + output := { + @required + @httpPayload + body: XmlTestOutput + } +} + +structure XmlTestInput { + @httpLabel + @required + xmlTest: String + @required + @httpPayload + body: XmlTestInputBody +} + + +structure XmlTestInputBody { + @xmlAttribute + @required + serverzeit: String + @required + requiredTest: String + requiredInt: Integer +} + + +structure XmlTestOutput { + @xmlAttribute + @required + serverzeit: String + @required + requiredTestStringConcat: String + requiredIntSquared: Integer +} +