diff --git a/build.sbt b/build.sbt index 5182b526..a54575dd 100644 --- a/build.sbt +++ b/build.sbt @@ -1,9 +1,12 @@ import sbt.Compile import sbt.Keys.cleanFiles -val releaseVersion = sys.env.getOrElse("TAG", "1.0.1-Gamma") + +val releaseVersion = sys.env.getOrElse("TAG", "1.1.0-Three.3") + 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", @@ -21,30 +24,28 @@ val githubSettings = Seq( ) ) -scalaVersion := "2.13.13" +scalaVersion := "3.3.1" -val defaultProjectSettings = Seq( - scalaVersion := "2.13.13", +val sharedSettings = Seq( + scalaVersion := "3.3.1", + scalacOptions ++= Seq("-Ykind-projector:underscores"), organization := "de.innfactory", version := releaseVersion ) ++ githubSettings -val sharedSettings = defaultProjectSettings lazy val smithy4play = project .in(file("smithy4play")) .enablePlugins(Smithy4sCodegenPlugin) .settings( sharedSettings, - addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full), +// addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full), scalaVersion := Dependencies.scalaVersion, Compile / smithy4sAllowedNamespaces := List("smithy.smithy4play", "aws.protocols"), Compile / smithy4sInputDirs := Seq( (ThisBuild / baseDirectory).value / "smithy4play" / "src" / "resources" / "META_INF" / "smithy" ), - Compile / smithy4sOutputDir := (ThisBuild / baseDirectory).value / "smithy4play" / "target" / "scala-2.13" / "src_managed" / "main", + Compile / smithy4sOutputDir := (Compile / sourceManaged).value / "main", name := "smithy4play", - scalacOptions += "-Ymacro-annotations", - Compile / compile / wartremoverWarnings ++= Warts.unsafe, libraryDependencies ++= Dependencies.list ) @@ -55,8 +56,6 @@ lazy val smithy4playTest = project 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" / "specs" / "testDefinitions" / "test", Compile / smithy4sInputDirs := Seq((ThisBuild / baseDirectory).value / "smithy4playTest" / "testSpecs"), diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 34f41550..56c51595 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,26 +3,26 @@ import sbt._ object Dependencies { - val playVersion = "2.9.2" - val typesafePlay = "com.typesafe.play" %% "play" % playVersion + val playVersion = "3.0.1" + val typesafePlay = "org.playframework" %% "play" % playVersion - val scalaVersion = "2.13.13" - val smithy4sVersion = "0.18.15" + val scalaVersion = "3.3.1" + val smithy4sVersion = "0.18.23" 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.3.6" - val alloyOpenapi = "com.disneystreaming.alloy" %% "alloy-openapi" % "0.3.6" + val alloyCore = "com.disneystreaming.alloy" % "alloy-core" % "0.3.11" + val alloyOpenapi = "com.disneystreaming.alloy" %% "alloy-openapi" % "0.3.11" - val classgraph = "io.github.classgraph" % "classgraph" % "4.8.168" - val smithyVersion = "1.45.0" + val classgraph = "io.github.classgraph" % "classgraph" % "4.8.174" + val smithyVersion = "1.50.0" val testTraits = "software.amazon.smithy" % "smithy-protocol-test-traits" % smithyVersion val scalatestPlus = - "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test - val cats = "org.typelevel" %% "cats-core" % "2.9.0" + "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.1" % Test + val cats = "org.typelevel" %% "cats-core" % "2.12.0" lazy val list = Seq( smithyCore, diff --git a/project/build.properties b/project/build.properties index fbf0bfc7..ae537384 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,2 +1,2 @@ sbt.version = 1.9.8 -scala.version = 2.13.2 +scala.version = 3.3.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 5ac98de8..2655002e 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,8 +1,7 @@ addSbtPlugin("com.codecommit" %% "sbt-github-packages" % "0.5.3") -addSbtPlugin("org.wartremover" %% "sbt-wartremover" % "3.1.6") addSbtPlugin("org.scalameta" %% "sbt-scalafmt" % "2.5.2") -addSbtPlugin("com.disneystreaming.smithy4s" %% "smithy4s-sbt-codegen" % "0.18.15") -addSbtPlugin("com.typesafe.play" %% "sbt-plugin" % "2.9.2") +addSbtPlugin("com.disneystreaming.smithy4s" %% "smithy4s-sbt-codegen" % "0.18.23") +addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.1") addSbtPlugin("org.scoverage" %% "sbt-scoverage" % "2.0.11") ThisBuild / dependencyOverrides ++= Seq( diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala index 4d8042a5..2a977810 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutableController.scala @@ -4,23 +4,25 @@ import com.github.plokhotnyuk.jsoniter_scala.core.ReaderConfig import de.innfactory.smithy4play.middleware.MiddlewareBase import play.api.mvc.ControllerComponents import play.api.routing.Router.Routes +import smithy4s.json.JsoniterCodecCompiler import smithy4s.kinds.FunctorAlgebra import scala.concurrent.ExecutionContext trait AutoRoutableController { + type Router = (Seq[MiddlewareBase], ReaderConfig, JsoniterCodecCompiler) => Routes - implicit def transformToRouter[Alg[_[_, _, _, _, _]], F[ - _ - ] <: ContextRoute[_]]( + implicit def transformToRouter[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[?]]( impl: FunctorAlgebra[Alg, F] )(implicit service: smithy4s.Service[Alg], ec: ExecutionContext, cc: ControllerComponents - ): (Seq[MiddlewareBase], ReaderConfig) => Routes = (middlewares: Seq[MiddlewareBase], readerConfig: ReaderConfig) => - new SmithyPlayRouter[Alg, F](impl, service).routes(middlewares, readerConfig) + ): Router = + (middlewares: Seq[MiddlewareBase], readerConfig: ReaderConfig, jsoniterCodecCompiler: JsoniterCodecCompiler) => + new SmithyPlayRouter[Alg, F](impl, service) + .routes(middlewares, readerConfig, jsoniterCodecCompiler) - val router: (Seq[MiddlewareBase], ReaderConfig) => Routes + val router: Router } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala index 198a7264..44cca6a5 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRouter.scala @@ -7,9 +7,9 @@ import io.github.classgraph.{ ClassGraph, ScanResult } import play.api.Application import play.api.mvc.ControllerComponents import play.api.routing.Router.Routes +import smithy4s.json.Json -import java.util.Optional -import javax.inject.{ Inject, Provider, Singleton } +import javax.inject.{ Inject, Singleton } import scala.concurrent.ExecutionContext import scala.jdk.CollectionConverters.CollectionHasAsScala import scala.util.Try @@ -23,8 +23,9 @@ class AutoRouter @Inject( config: Config ) extends BaseRouter { - private val pkg = config.getString("smithy4play.autoRoutePackage") - private val readerConfig = ReaderConfig.fromApplicationConfig(config) + private val pkg = config.getString("smithy4play.autoRoutePackage") + private val readerConfig = ReaderConfig.fromApplicationConfig(config) + private val jsoniterCodecCompiler = Json.jsoniter.fromApplicationConfig(config) override val controllers: Seq[Routes] = { val classGraphScanner: ScanResult = new ClassGraph().enableAllInfo().acceptPackages(pkg).scan() @@ -34,14 +35,18 @@ class AutoRouter @Inject( }.toOption.getOrElse(Seq(validateAuthMiddleware)) logger.debug(s"[AutoRouter] found ${controllers.size().toString} controllers") logger.debug(s"[AutoRouter] found ${middlewares.size.toString} middlewares") - val routes = controllers.asScala.map(_.loadClass(true)).map(clazz => createFromClass(clazz, middlewares)).toSeq + val routes = controllers.asScala + .filter(!_.isAbstract) + .map(_.loadClass(true)) + .map(clazz => createFromClass(clazz, middlewares)) + .toSeq classGraphScanner.close() routes } - private def createFromClass(clazz: Class[_], middlewares: Seq[MiddlewareBase]): Routes = + private def createFromClass(clazz: Class[?], middlewares: Seq[MiddlewareBase]): Routes = app.injector.instanceOf(clazz) match { - case c: AutoRoutableController => c.router(middlewares, readerConfig) + case c: AutoRoutableController => c.router(middlewares, readerConfig, jsoniterCodecCompiler) } } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutingMacro.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutingMacro.scala deleted file mode 100644 index b27b6c1f..00000000 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/AutoRoutingMacro.scala +++ /dev/null @@ -1,29 +0,0 @@ -package de.innfactory.smithy4play - -import scala.reflect.macros.whitebox - -object AutoRoutingMacro { - def impl(c: whitebox.Context)(annottees: c.Tree*): c.Tree = { - import c.universe._ - annottees match { - case List( - q"$mods class $className $ctorMods(...$paramss) extends { ..$earlydefns } with ..$parentss { $self => ..$body }" - ) => - q"""$mods class $className $ctorMods(...$paramss) - extends { ..$earlydefns } - with ..$parentss - with de.innfactory.smithy4play.AutoRoutableController - { $self => - override val router: - (Seq[de.innfactory.smithy4play.middleware.MiddlewareBase], com.github.plokhotnyuk.jsoniter_scala.core.ReaderConfig) - => play.api.routing.Router.Routes = this - ..$body } - """ - case _ => - c.abort( - c.enclosingPosition, - "RegisterClass: An AutoRouter Annotation on this type of Class is not supported." - ) - } - } -} diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/BaseRouter.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/BaseRouter.scala index e5f6397d..ba887931 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/BaseRouter.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/BaseRouter.scala @@ -14,7 +14,7 @@ abstract class BaseRouter(implicit implicit def transformToRouter[Alg[_[_, _, _, _, _]], F[ _ - ] <: ContextRoute[_]]( + ] <: ContextRoute[?]]( impl: FunctorAlgebra[Alg, F] )(implicit serviceProvider: smithy4s.Service[Alg], diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala index 3c769e65..3d2b7756 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/CodecDecider.scala @@ -2,22 +2,23 @@ package de.innfactory.smithy4play import com.github.plokhotnyuk.jsoniter_scala.core.ReaderConfig import play.api.http.MimeTypes -import smithy4s.capability.instances.either._ +import smithy4s.capability.instances.either.* import smithy4s.codecs.Writer.CachedCompiler -import smithy4s.codecs._ +import smithy4s.codecs.* import smithy4s.http.{ HttpResponse, HttpRestSchema, Metadata, MetadataError } -import smithy4s.json.Json +import smithy4s.json.{ Json, JsoniterCodecCompiler } import smithy4s.kinds.PolyFunction import smithy4s.schema.CachedSchemaCompiler import smithy4s.xml.Xml import smithy4s.{ codecs, Blob } -case class CodecDecider(readerConfig: ReaderConfig) { +case class CodecDecider( + readerConfig: ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler +) { private val jsonCodecs = Json.payloadCodecs - .withJsoniterCodecCompiler( - Json.jsoniter - ) + .withJsoniterCodecCompiler(jsoniterCodecCompiler) .withJsoniterReaderConfig(readerConfig) private val jsonEncoder: BlobEncoder.Compiler = jsonCodecs.encoders @@ -28,7 +29,7 @@ case class CodecDecider(readerConfig: ReaderConfig) { def encoder( contentType: Seq[String] ): CachedSchemaCompiler[codecs.BlobEncoder] = - contentType match { + contentType.map(_.split(";").head) match { case Seq(MimeTypes.JSON) => jsonEncoder case Seq(MimeTypes.XML) => Xml.encoders case _ => @@ -38,18 +39,18 @@ case class CodecDecider(readerConfig: ReaderConfig) { def requestDecoder( contentType: Seq[String] - ): CachedSchemaCompiler[Decoder[Either[Throwable, *], PlayHttpRequest[Blob], *]] = - HttpRestSchema.combineDecoderCompilers[Either[Throwable, *], PlayHttpRequest[Blob]]( + ): CachedSchemaCompiler[Decoder[Either[Throwable, _], PlayHttpRequest[Blob], _]] = + HttpRestSchema.combineDecoderCompilers[Either[Throwable, _], PlayHttpRequest[Blob]]( metadataDecoder .mapK( - Decoder.in[Either[MetadataError, *]].composeK[Metadata, PlayHttpRequest[Blob]](_.metadata) + Decoder.in[Either[MetadataError, _]].composeK[Metadata, PlayHttpRequest[Blob]](_.metadata) ) - .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], PlayHttpRequest[Blob], *]]], + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, _], PlayHttpRequest[Blob], _]]], decoder(contentType) .mapK( - Decoder.in[Either[PayloadError, *]].composeK[Blob, PlayHttpRequest[Blob]](_.body) + Decoder.in[Either[PayloadError, _]].composeK[Blob, PlayHttpRequest[Blob]](_.body) ) - .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], PlayHttpRequest[Blob], *]]], + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, _], PlayHttpRequest[Blob], _]]], _ => Right(()) )(eitherZipper) @@ -68,22 +69,22 @@ case class CodecDecider(readerConfig: ReaderConfig) { def httpResponseDecoder( contentType: Seq[String] - ): CachedSchemaCompiler[Decoder[Either[Throwable, *], HttpResponse[Blob], *]] = - HttpRestSchema.combineDecoderCompilers[Either[Throwable, *], HttpResponse[Blob]]( + ): CachedSchemaCompiler[Decoder[Either[Throwable, _], HttpResponse[Blob], _]] = + HttpRestSchema.combineDecoderCompilers[Either[Throwable, _], HttpResponse[Blob]]( metadataDecoder .mapK( Decoder - .in[Either[MetadataError, *]] + .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], *]]], + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, _], HttpResponse[Blob], _]]], decoder(contentType) .mapK( - Decoder.in[Either[PayloadError, *]].composeK[Blob, HttpResponse[Blob]](_.body) + Decoder.in[Either[PayloadError, _]].composeK[Blob, HttpResponse[Blob]](_.body) ) - .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, *], HttpResponse[Blob], *]]], + .asInstanceOf[CachedSchemaCompiler[Decoder[Either[Throwable, _], HttpResponse[Blob], _]]], _ => Right(()) )(eitherZipper) @@ -108,9 +109,9 @@ case class CodecDecider(readerConfig: ReaderConfig) { (insensitive, value) }) ) - private val httpRequestBlobPipe: PolyFunction[Encoder[Blob, *], Writer[HttpResponse[Blob], *]] = + 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], *]] = + private val httpRequestMetadataPipe: PolyFunction[Encoder[Metadata, _], Writer[HttpResponse[Blob], _]] = smithy4s.codecs.Encoder.pipeToWriterK(httpRequestMetadataLift) private val blobLift: Writer[EndpointRequest, Blob] = @@ -121,15 +122,15 @@ case class CodecDecider(readerConfig: ReaderConfig) { (insensitive, value) }) ) - private val blobPipe: PolyFunction[Encoder[Blob, *], Writer[EndpointRequest, *]] = + private val blobPipe: PolyFunction[Encoder[Blob, _], Writer[EndpointRequest, _]] = smithy4s.codecs.Encoder.pipeToWriterK[EndpointRequest, Blob](blobLift) - private val metadataPipe: PolyFunction[Encoder[Metadata, *], Writer[EndpointRequest, *]] = + private val metadataPipe: PolyFunction[Encoder[Metadata, _], Writer[EndpointRequest, _]] = smithy4s.codecs.Encoder.pipeToWriterK(metadataLift) def decoder( contentType: Seq[String] ): CachedSchemaCompiler[BlobDecoder] = - contentType match { + contentType.map(_.split(";").head) match { case Seq(MimeTypes.JSON) => jsonDecoder case Seq(MimeTypes.XML) => Xml.decoders case _ => diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/RoutingContext.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/RoutingContext.scala index 88eaee32..46f7b916 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/RoutingContext.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/RoutingContext.scala @@ -10,9 +10,9 @@ case class RoutingContext( attributes: Map[String, Any], requestHeader: RequestHeader ) { - def hasHints(s: ShapeTag.Companion[_]): Boolean = hasEndpointHints(s) || hasServiceHints(s) - def hasServiceHints(s: ShapeTag.Companion[_]): Boolean = serviceHints.has(s.tagInstance) - def hasEndpointHints(s: ShapeTag.Companion[_]): Boolean = endpointHints.has(s.tagInstance) + def hasHints(s: ShapeTag.Companion[?]): Boolean = hasEndpointHints(s) || hasServiceHints(s) + def hasServiceHints(s: ShapeTag.Companion[?]): Boolean = serviceHints.has(s.tagInstance) + def hasEndpointHints(s: ShapeTag.Companion[?]): Boolean = endpointHints.has(s.tagInstance) } object RoutingContext { diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala index 64b682f5..7cccc110 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayEndpoint.scala @@ -3,6 +3,7 @@ package de.innfactory.smithy4play import cats.data.{ EitherT, Kleisli } import de.innfactory.smithy4play import de.innfactory.smithy4play.middleware.MiddlewareBase +import org.apache.pekko.util.ByteString import play.api.mvc._ import smithy4s.codecs.PayloadError import smithy4s.http._ @@ -13,7 +14,7 @@ import smithy4s.{ Blob, Endpoint, Service } import javax.inject.Inject import scala.concurrent.{ ExecutionContext, Future } -class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[_, _, _, _, _], I, E, O, SI, SO]( +class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[?], Op[_, _, _, _, _], I, E, O, SI, SO]( service: Service[Alg], impl: FunctorInterpreter[Op, F], middleware: Seq[MiddlewareBase], @@ -27,10 +28,11 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[_, _ private val endpointHints = endpoint.hints private val serviceContentType: String = serviceHints.toMimeType - private implicit val inputSchema: Schema[I] = endpoint.input - private implicit val outputSchema: Schema[O] = endpoint.output - - def handler(v1: RequestHeader): Handler = + private implicit val inputSchema: Schema[I] = endpoint.input + private implicit val outputSchema: Schema[O] = endpoint.output + private val outputMetadataEncoder: Metadata.Encoder[O] = + Metadata.Encoder.fromSchema(outputSchema) + def handler(v1: RequestHeader): Handler = httpEndpoint.map { httpEp => Action.async(parse.raw) { implicit request => if (request.body.size > 0 && request.body.asBytes().isEmpty) { @@ -63,9 +65,13 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[_, _ private def mapToEndpointResult( statusCode: Int - )(output: O)(implicit defaultContentType: ContentType): HttpResponse[Blob] = + )(output: O)(implicit defaultContentType: ContentType): HttpResponse[Blob] = { + val outputContentType = outputMetadataEncoder.encode(output).headers.get(CaseInsensitive("content-type")) match { + case Some(value) => value + case None => Seq(defaultContentType.value) + } codecDecider - .httpMessageEncoder(Seq(defaultContentType.value)) + .httpMessageEncoder(outputContentType) .fromSchema(outputSchema) .write( HttpResponse( @@ -75,6 +81,7 @@ class SmithyPlayEndpoint[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[_], Op[_, _ ), output ) + } private def getPathParams( v1: RequestHeader, diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala index 268c8432..af9e15d2 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/SmithyPlayRouter.scala @@ -8,28 +8,30 @@ import play.api.mvc.{ AbstractController, ControllerComponents, Handler, Request import play.api.routing.Router.Routes import smithy4s.codecs.{ BlobEncoder, PayloadDecoder, PayloadEncoder } import smithy4s.http.{ HttpEndpoint, PathSegment } -import smithy4s.json.{ Json, JsonPayloadCodecCompiler } +import smithy4s.json.{ Json, JsonPayloadCodecCompiler, JsoniterCodecCompiler } import smithy4s.kinds.{ FunctorAlgebra, Kind1, PolyFunction5 } import smithy4s.schema.CachedSchemaCompiler import smithy4s.xml.Xml import scala.concurrent.ExecutionContext -class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[ - _ -] <: ContextRoute[_]]( +class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[_] <: ContextRoute[?]]( impl: FunctorAlgebra[Alg, F], service: smithy4s.Service[Alg] )(implicit cc: ControllerComponents, ec: ExecutionContext) extends AbstractController(cc) { - def routes(middlewares: Seq[MiddlewareBase], readerConfig: ReaderConfig): Routes = { + def routes( + middlewares: Seq[MiddlewareBase], + readerConfig: ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler + ): Routes = { 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[_]]] = + val endpoints: Seq[service.Endpoint[?, ?, ?, ?, ?]] = service.endpoints + val httpEndpoints: Seq[Either[HttpEndpoint.HttpEndpointError, HttpEndpoint[?]]] = endpoints.map(ep => HttpEndpoint.cast(ep.schema)) - val codecDecider = CodecDecider(readerConfig) + val codecDecider = CodecDecider(readerConfig, jsoniterCodecCompiler) new PartialFunction[RequestHeader, Handler] { override def isDefinedAt(x: RequestHeader): Boolean = { @@ -64,7 +66,7 @@ class SmithyPlayRouter[Alg[_[_, _, _, _, _]], F[ private def checkIfRequestHeaderMatchesEndpoint( x: RequestHeader, - ep: HttpEndpoint[_] + ep: HttpEndpoint[?] ): Boolean = { ep.path.map { case PathSegment.StaticSegment(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 29ff88e0..46f84738 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/GenericAPIClient.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/GenericAPIClient.scala @@ -4,6 +4,7 @@ import cats.data.Kleisli import com.github.plokhotnyuk.jsoniter_scala.core.ReaderConfig import de.innfactory.smithy4play.{ ClientResponse, CodecDecider, RunnableClientRequest } import smithy4s.Service +import smithy4s.json.{ Json, JsoniterCodecCompiler } import smithy4s.kinds.{ Kind1, PolyFunction5 } import scala.concurrent.ExecutionContext @@ -12,11 +13,18 @@ private class GenericAPIClient[Alg[_[_, _, _, _, _]]]( service: Service[Alg], client: RequestClient, readerConfig: ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler, additionalSuccessCodes: List[Int] = List.empty )(implicit ec: ExecutionContext) { private val smithyPlayClient = - new SmithyPlayClient("/", service, client, CodecDecider(readerConfig), additionalSuccessCodes) + new SmithyPlayClient( + "/", + service, + client, + CodecDecider(readerConfig, jsoniterCodecCompiler), + additionalSuccessCodes + ) /* Takes a service and creates a Transformation[Op, ClientRequest] */ private def transformer(): Alg[Kind1[RunnableClientRequest]#toKind5] = @@ -57,16 +65,18 @@ object GenericAPIClient { client: RequestClient, additionalHeaders: Option[Map[String, Seq[String]]], additionalSuccessCodes: List[Int] = List.empty, - readerConfig: ReaderConfig = ReaderConfig + readerConfig: ReaderConfig = ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler = Json.jsoniter )(implicit ec: ExecutionContext): Alg[Kind1[ClientResponse]#toKind5] = - apply(service, additionalHeaders, additionalSuccessCodes, client, readerConfig) + apply(service, additionalHeaders, additionalSuccessCodes, client, readerConfig, jsoniterCodecCompiler) def withClient( client: RequestClient, additionalSuccessCodes: List[Int] = List.empty, - readerConfig: ReaderConfig = ReaderConfig + readerConfig: ReaderConfig = ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler = Json.jsoniter )(implicit ec: ExecutionContext): Alg[Kind1[RunnableClientRequest]#toKind5] = - apply(service, client, additionalSuccessCodes, readerConfig) + apply(service, client, additionalSuccessCodes, readerConfig, jsoniterCodecCompiler) } @@ -74,17 +84,21 @@ object GenericAPIClient { serviceI: Service[Alg], client: RequestClient, additionalSuccessCodes: List[Int], - readerConfig: ReaderConfig + readerConfig: ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler )(implicit ec: ExecutionContext): Alg[Kind1[RunnableClientRequest]#toKind5] = - new GenericAPIClient(serviceI, client, readerConfig, additionalSuccessCodes).transformer() + new GenericAPIClient(serviceI, client, readerConfig, jsoniterCodecCompiler, additionalSuccessCodes).transformer() def apply[Alg[_[_, _, _, _, _]]]( serviceI: Service[Alg], additionalHeaders: Option[Map[String, Seq[String]]], additionalSuccessCodes: List[Int], client: RequestClient, - readerConfig: ReaderConfig + readerConfig: ReaderConfig, + jsoniterCodecCompiler: JsoniterCodecCompiler )(implicit ec: ExecutionContext): Alg[Kind1[ClientResponse]#toKind5] = - new GenericAPIClient(serviceI, client, readerConfig, additionalSuccessCodes).transformer(additionalHeaders) + new GenericAPIClient(serviceI, client, readerConfig, jsoniterCodecCompiler, additionalSuccessCodes).transformer( + additionalHeaders + ) } 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 74c1d947..0f96e64f 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/client/SmithyPlayClientEndpoint.scala @@ -81,7 +81,7 @@ private[smithy4play] class SmithyPlayClientEndpoint[Op[_, _, _, _, _], I, E, O, .map(o => HttpResponse(response.statusCode, headers, o)) .leftMap { case error: PayloadError => - SmithyPlayClientEndpointErrorResponse(error.expected.getBytes(), response.statusCode) + SmithyPlayClientEndpointErrorResponse(error.toString().getBytes(), response.statusCode) case error: MetadataError => SmithyPlayClientEndpointErrorResponse(error.getMessage().getBytes(), response.statusCode) } diff --git a/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala b/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala index c20831b5..5514e356 100644 --- a/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala +++ b/smithy4play/src/main/scala/de/innfactory/smithy4play/package.scala @@ -11,10 +11,10 @@ import play.api.Logger import play.api.http.MimeTypes import play.api.libs.json.{ JsValue, Json, OFormat } import play.api.mvc.{ Headers, RequestHeader } -import smithy4s.{ Blob, Hints } import smithy4s.http.{ CaseInsensitive, HttpEndpoint, HttpResponse, Metadata } +import smithy4s.{ Blob, Hints } +import smithy4s.json.JsoniterCodecCompiler -import scala.annotation.{ compileTimeOnly, StaticAnnotation } import scala.concurrent.Future import scala.language.experimental.macros import scala.util.Try @@ -104,6 +104,17 @@ package object smithy4play { } } + implicit class EnhancedJsoniterCodecCompiler(codec: JsoniterCodecCompiler) { + + def fromApplicationConfig(config: Config): JsoniterCodecCompiler = { + val maxArity = + Try(config.getInt("smithy4play.jsoniterCodecCompiler.maxArity")).toOption + + codec + .withMaxArity(maxArity.getOrElse(JsoniterCodecCompiler.defaultMaxArity)) + } + } + 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 @@ -133,7 +144,7 @@ package object smithy4play { } object Smithy4PlayError { - implicit val format = Json.format[Smithy4PlayError] + implicit val format: OFormat[Smithy4PlayError] = Json.format[Smithy4PlayError] } private[smithy4play] val logger: slf4j.Logger = Logger("smithy4play").logger @@ -145,17 +156,10 @@ package object smithy4play { private[smithy4play] def matchRequestPath( x: RequestHeader, - ep: HttpEndpoint[_] + ep: HttpEndpoint[?] ): Option[Map[String, String]] = ep.matches(x.path.replaceFirst("/", "").split("/").filter(_.nonEmpty)) - @compileTimeOnly( - "Macro failed to expand. \"Add: scalacOptions += \"-Ymacro-annotations\"\" to project settings" - ) - class AutoRouting extends StaticAnnotation { - def macroTransform(annottees: Any*): Any = macro AutoRoutingMacro.impl - } - private[smithy4play] trait Showable { this: Product => override def toString: String = this.show diff --git a/smithy4playTest/app/Module.scala b/smithy4playTest/app/Module.scala index 0c5297a7..79550275 100755 --- a/smithy4playTest/app/Module.scala +++ b/smithy4playTest/app/Module.scala @@ -1,13 +1,13 @@ import com.google.inject.AbstractModule import controller.middlewares.MiddlewareRegistry import de.innfactory.smithy4play.middleware.MiddlewareRegistryBase -import play.api.libs.concurrent.AkkaGuiceSupport +import play.api.libs.concurrent.PekkoGuiceSupport /** This module handles the bindings for the API to the Slick implementation. * * https://www.playframework.com/documentation/latest/ScalaDependencyInjection#Programmatic-bindings */ -class Module extends AbstractModule with AkkaGuiceSupport { +class Module extends AbstractModule with PekkoGuiceSupport { override def configure(): Unit = bind(classOf[MiddlewareRegistryBase]).to(classOf[MiddlewareRegistry]) diff --git a/smithy4playTest/app/controller/TestController.scala b/smithy4playTest/app/controller/TestController.scala index 20ec6951..6ec10dd2 100644 --- a/smithy4playTest/app/controller/TestController.scala +++ b/smithy4playTest/app/controller/TestController.scala @@ -1,21 +1,25 @@ package controller import cats.data.{ EitherT, Kleisli } +import com.github.plokhotnyuk.jsoniter_scala.core.ReaderConfig import controller.models.TestError -import de.innfactory.smithy4play.{ AutoRouting, ContextRoute, ContextRouteError } +import de.innfactory.smithy4play.middleware.MiddlewareBase +import de.innfactory.smithy4play.{ AutoRoutableController, ContextRoute, ContextRouteError } import play.api.mvc.ControllerComponents +import play.api.routing.Router.Routes import smithy4s.Blob -import testDefinitions.test._ +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] { +) extends TestControllerService[ContextRoute] + with AutoRoutableController { + override val router: Router = this override def test(): ContextRoute[SimpleTestResponse] = Kleisli { rc => rc.attributes.get("Not") match { @@ -67,4 +71,8 @@ class TestController @Inject() (implicit override def testWithOtherStatusCode(): ContextRoute[Unit] = Kleisli { rc => EitherT.rightT[Future, ContextRouteError](()) } + + override def testWithJsonInputAndBlobOutput(body: JsonInput): ContextRoute[BlobResponse] = Kleisli { rc => + EitherT.rightT[Future, ContextRouteError](BlobResponse(Blob(body.message), "image/png")) + } } diff --git a/smithy4playTest/app/controller/XmlController.scala b/smithy4playTest/app/controller/XmlController.scala index 964ec18e..c4461a20 100644 --- a/smithy4playTest/app/controller/XmlController.scala +++ b/smithy4playTest/app/controller/XmlController.scala @@ -2,32 +2,39 @@ package controller import cats.data.{ EitherT, Kleisli } import cats.implicits.catsSyntaxEitherId -import de.innfactory.smithy4play.{ AutoRouting, ContextRoute, ContextRouteError } +import com.github.plokhotnyuk.jsoniter_scala.core.ReaderConfig +import de.innfactory.smithy4play.middleware.MiddlewareBase +import de.innfactory.smithy4play.{ AutoRoutableController, ContextRoute, ContextRouteError } import play.api.mvc.ControllerComponents +import play.api.routing.Router.Routes 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] { +) extends XmlControllerDef[ContextRoute] + with AutoRoutableController { + override val router: Router = this override def xmlTestWithInputAndOutput( xmlTest: String, - body: XmlTestInputBody + body: XmlTestInputBody, + contentType: Option[String] ): ContextRoute[XmlTestWithInputAndOutputOutput] = Kleisli { _ => EitherT( Future( XmlTestWithInputAndOutputOutput( - XmlTestOutput(body.serverzeit, body.requiredTest + xmlTest, body.requiredInt.map(i => i * i)) + XmlTestOutput(body.serverzeit, body.requiredTest + xmlTest, body.requiredInt.map(i => i * i)), + contentType ) .asRight[ContextRouteError] ) ) } + } diff --git a/smithy4playTest/test/TestControllerTest.scala b/smithy4playTest/test/TestControllerTest.scala index 7bdb8c9b..f03ea09c 100644 --- a/smithy4playTest/test/TestControllerTest.scala +++ b/smithy4playTest/test/TestControllerTest.scala @@ -11,9 +11,10 @@ import play.api.libs.json.{ Json, OWrites } import play.api.mvc.Result import play.api.test.FakeRequest import play.api.test.Helpers._ -import smithy4s.Blob +import smithy4s.{ Blob, Document } import smithy4s.http.CaseInsensitive import testDefinitions.test.{ + JsonInput, SimpleTestResponse, TestControllerServiceGen, TestRequestBody, @@ -167,6 +168,14 @@ class TestControllerTest extends TestBase { pngAsBytes mustBe result.body.body } + "route with json body to Blob Endpoint" in { + val testString = "StringToBeParsedCorrectly" + val result = genericClient.testWithJsonInputAndBlobOutput(JsonInput(testString)).awaitRight(global, 5.hours) + + result.statusCode mustBe 200 + testString mustBe result.body.body.toUTF8String + } + "route to Auth Test" in { val result = genericClient.testAuth().awaitLeft diff --git a/smithy4playTest/test/XmlControllerTest.scala b/smithy4playTest/test/XmlControllerTest.scala index d023a5a6..215a00fc 100644 --- a/smithy4playTest/test/XmlControllerTest.scala +++ b/smithy4playTest/test/XmlControllerTest.scala @@ -4,11 +4,11 @@ import models.NodeImplicits.NodeEnhancer import models.TestBase import play.api.Application import play.api.inject.guice.GuiceApplicationBuilder -import play.api.libs.json.{Json, OFormat} +import play.api.libs.json.{ Json, OFormat } import play.api.test.FakeRequest import play.api.test.Helpers._ import smithy4s.http.CaseInsensitive -import testDefinitions.test.{XmlControllerDefGen, XmlTestInputBody, XmlTestOutput} +import testDefinitions.test.{ XmlControllerDefGen, XmlTestInputBody, XmlTestOutput } import scala.concurrent.ExecutionContext.Implicits.global @@ -33,6 +33,53 @@ class XmlControllerTest extends TestBase { res.body.body.requiredTestStringConcat mustBe "ThisGetsConcat" } + "route to xml with charset in header endpoint with smithy client" in { + val res = genericClient + .xmlTestWithInputAndOutput( + "Concat", + XmlTestInputBody("05.02.2024", "ThisGets", Some(10)), + Some("application/xml; charset=utf-8") + ) + .awaitRight + + res.body.body.requiredIntSquared mustBe Some(100) + res.body.body.requiredTestStringConcat mustBe "ThisGetsConcat" + res.headers.get(CaseInsensitive("content-type")) mustBe Some(List("application/xml; charset=utf-8")) + } + + "route to xml with charset in header 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; charset=utf-8")) + .withXmlBody( + xml + ) + ).get + status(request) mustBe 200 + + val result = scala.xml.XML.loadString(contentAsString(request)) + val resContentType = contentType(request) + val resCharset = charset(request) + + + result.normalize mustBe + + {concatVal1 + concatVal2} + + {squareTest * squareTest} + .normalize + resContentType.map(_ + "; charset=" + resCharset.getOrElse("")) mustBe Some("application/xml; charset=utf-8") + } + "route to xml test endpoint with external client" in { val concatVal1 = "ConcatThis" val concatVal2 = "Test2" @@ -115,13 +162,13 @@ class XmlControllerTest extends TestBase { } "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( + 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")) @@ -130,7 +177,7 @@ class XmlControllerTest extends TestBase { ) ).get status(request) mustBe 200 - val result = contentAsJson(request).as[XmlTestOutput] + 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/TestBase.scala b/smithy4playTest/test/models/TestBase.scala index f173b0d3..671aedeb 100644 --- a/smithy4playTest/test/models/TestBase.scala +++ b/smithy4playTest/test/models/TestBase.scala @@ -2,27 +2,26 @@ package models import de.innfactory.smithy4play.EndpointRequest import de.innfactory.smithy4play.client.RequestClient -import org.scalatestplus.play.{BaseOneAppPerSuite, FakeApplicationFactory, PlaySpec} +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 play.api.test.Helpers.{ route, writeableOf_AnyContentAsEmpty } import smithy4s.Blob -import smithy4s.http.{CaseInsensitive, HttpResponse} +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[String, Seq[String]], - result: EndpointRequest - ): Future[HttpResponse[Blob]] = { + method: String, + path: String, + headers: Map[String, 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, v))): _*) val res = diff --git a/smithy4playTest/test/models/TestJson.scala b/smithy4playTest/test/models/TestJson.scala index 094b46bd..9c22a7de 100644 --- a/smithy4playTest/test/models/TestJson.scala +++ b/smithy4playTest/test/models/TestJson.scala @@ -1,9 +1,10 @@ package models -import play.api.libs.json.Json +import play.api.libs.json.{Json, Reads, Writes} case class TestJson(message: Option[String]) object TestJson { - implicit val format = Json.format[TestJson] + given Writes[TestJson] = Json.format[TestJson] + given Reads[TestJson] = Json.reads[TestJson] } diff --git a/smithy4playTest/testSpecs/TestController.smithy b/smithy4playTest/testSpecs/TestController.smithy index 066ffd2c..2de835da 100644 --- a/smithy4playTest/testSpecs/TestController.smithy +++ b/smithy4playTest/testSpecs/TestController.smithy @@ -14,6 +14,7 @@ service TestControllerService { Health TestWithBlob TestWithQuery + TestWithJsonInputAndBlobOutput TestThatReturnsError TestAuth TestWithOtherStatusCode @@ -27,6 +28,17 @@ service TestControllerService { operation TestWithOtherStatusCode { } +@auth([]) +@http(method: "POST", uri: "/jsoninput/bloboutput", code: 200) +operation TestWithJsonInputAndBlobOutput { + input:= { + @httpPayload + @required + body: JsonInput + } + output: BlobResponse +} + @auth([]) @http(method: "POST", uri: "/blob", code: 200) operation TestWithBlob { @@ -134,6 +146,12 @@ structure TestResponseBody { bodyMessage: String } + +structure JsonInput { + @required + message: String +} + @http(method: "GET", uri: "/auth", code: 200) operation TestAuth { } diff --git a/smithy4playTest/testSpecs/XmlController.smithy b/smithy4playTest/testSpecs/XmlController.smithy index 9daa33e2..5d6397b9 100644 --- a/smithy4playTest/testSpecs/XmlController.smithy +++ b/smithy4playTest/testSpecs/XmlController.smithy @@ -15,6 +15,8 @@ service XmlControllerDef { operation XmlTestWithInputAndOutput { input: XmlTestInput output := { + @httpHeader("content-type") + contentType: String @required @httpPayload body: XmlTestOutput @@ -22,6 +24,8 @@ operation XmlTestWithInputAndOutput { } structure XmlTestInput { + @httpHeader("content-type") + contentType: String @httpLabel @required xmlTest: String