From af64915e2ef4ff47f5f8bba6427a7222048c1477 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Fri, 7 May 2021 11:57:16 +0200 Subject: [PATCH 01/35] adjusted code from PoC, wip on runnable example --- build.sbt | 39 +++- .../tapir/integ/cats}/CatsMonadError.scala | 6 +- project/plugins.sbt | 2 + .../http4s/Http4sServerInterpreter.scala | 1 + .../server/http4s/Http4sServerTest.scala | 1 + .../examples/src/main/resources/event.json | 41 +++++ .../aws/examples/HelloHandler.scala | 42 +++++ .../serverless/aws/examples/HelloSam.scala | 22 +++ .../aws/lambda/AwsBodyListener.scala | 11 ++ .../aws/lambda/AwsRequestBody.scala | 34 ++++ .../aws/lambda/AwsServerInterpreter.scala | 61 +++++++ .../aws/lambda/AwsServerOptions.scala | 32 ++++ .../aws/lambda/AwsServerRequest.scala | 24 +++ .../aws/lambda/AwsToResponseBody.scala | 56 ++++++ .../tapir/serverless/aws/lambda/model.scala | 20 +++ .../tapir/serverless/aws/lambda/package.scala | 7 + .../aws/sam/AwsSamInterpreter.scala | 67 +++++++ .../serverless/aws/sam/AwsSamOptions.scala | 10 ++ .../aws/sam/AwsSamTemplateEncoders.scala | 37 ++++ .../tapir/serverless/aws/sam/Printer.scala | 167 ++++++++++++++++++ .../sttp/tapir/serverless/aws/sam/model.scala | 38 ++++ 21 files changed, 713 insertions(+), 5 deletions(-) rename {server/http4s-server/src/main/scala/sttp/tapir/server/http4s => integrations/cats/src/main/scala/sttp/tapir/integ/cats}/CatsMonadError.scala (84%) create mode 100644 serverless/aws/examples/src/main/resources/event.json create mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala create mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsBodyListener.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala create mode 100644 serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala create mode 100644 serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala create mode 100644 serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala create mode 100644 serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/Printer.scala create mode 100644 serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala diff --git a/build.sbt b/build.sbt index c4d34e95b4..7929699300 100644 --- a/build.sbt +++ b/build.sbt @@ -270,6 +270,7 @@ lazy val cats: ProjectMatrix = (projectMatrix in file("integrations/cats")) name := "tapir-cats", libraryDependencies ++= Seq( "org.typelevel" %%% "cats-core" % "2.6.0", + "org.typelevel" %%% "cats-effect" % Versions.catsEffect, scalaTest.value % Test, scalaCheck.value % Test, scalaTestPlusScalaCheck.value % Test, @@ -384,8 +385,8 @@ lazy val circeJson: ProjectMatrix = (projectMatrix in file("json/circe")) libraryDependencies ++= Seq( "io.circe" %%% "circe-core" % Versions.circe, "io.circe" %%% "circe-parser" % Versions.circe, + "io.circe" %%% "circe-generic" % Versions.circe, scalaTest.value % Test, - "io.circe" %%% "circe-generic" % Versions.circe % Test ) ) .jvmPlatform(scalaVersions = allScalaVersions) @@ -762,7 +763,7 @@ lazy val http4sServer: ProjectMatrix = (projectMatrix in file("server/http4s-ser ) ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core, serverTests % Test) + .dependsOn(core, cats, serverTests % Test) lazy val sttpStubServer: ProjectMatrix = (projectMatrix in file("server/sttp-stub-server")) .settings(commonJvmSettings) @@ -861,6 +862,40 @@ lazy val zioServer: ProjectMatrix = (projectMatrix in file("server/zio-http4s-se .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(zio, http4sServer, serverTests % Test) +// serverless + +lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda")) + .settings(commonJvmSettings) + .settings( + name := "tapir-aws-lambda", + libraryDependencies ++= Seq( + "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "1.0.0" + ) + ) + .jvmPlatform(scalaVersions = allScalaVersions) + .dependsOn(core, cats, circeJson, tests) + +lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) + .settings(commonJvmSettings) + .settings( + name := "tapir-aws-sam", + libraryDependencies ++= Seq( + "io.circe" %% "circe-yaml" % Versions.circeYaml, + "io.circe" %% "circe-generic" % Versions.circe, + ) + ) + .jvmPlatform(scalaVersions = allScalaVersions) + .dependsOn(core) + +lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) + .enablePlugins(DockerPlugin) + .settings(commonJvmSettings) + .settings( + name := "tapir-aws-examples" + ) + .jvmPlatform(scalaVersions = allScalaVersions) + .dependsOn(awsLambda, awsSam) + // client lazy val clientTests: ProjectMatrix = (projectMatrix in file("client/tests")) diff --git a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/CatsMonadError.scala b/integrations/cats/src/main/scala/sttp/tapir/integ/cats/CatsMonadError.scala similarity index 84% rename from server/http4s-server/src/main/scala/sttp/tapir/server/http4s/CatsMonadError.scala rename to integrations/cats/src/main/scala/sttp/tapir/integ/cats/CatsMonadError.scala index 9793cea610..7c642abbd5 100644 --- a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/CatsMonadError.scala +++ b/integrations/cats/src/main/scala/sttp/tapir/integ/cats/CatsMonadError.scala @@ -1,9 +1,9 @@ -package sttp.tapir.server.http4s +package sttp.tapir.integ.cats import cats.effect.Sync import sttp.monad.MonadError -private[http4s] class CatsMonadError[F[_]](implicit F: Sync[F]) extends MonadError[F] { +class CatsMonadError[F[_]](implicit F: Sync[F]) extends MonadError[F] { override def unit[T](t: T): F[T] = F.pure(t) override def map[T, T2](fa: F[T])(f: T => T2): F[T2] = F.map(fa)(f) override def flatMap[T, T2](fa: F[T])(f: T => F[T2]): F[T2] = F.flatMap(fa)(f) @@ -13,4 +13,4 @@ private[http4s] class CatsMonadError[F[_]](implicit F: Sync[F]) extends MonadErr override def suspend[T](t: => F[T]): F[T] = F.suspend(t) override def flatten[T](ffa: F[F[T]]): F[T] = F.flatten(ffa) override def ensure[T](f: F[T], e: => F[Unit]): F[T] = F.guarantee(f)(e) -} +} \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index 06d20c521f..1da0515621 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -10,3 +10,5 @@ addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.8.0") addSbtPlugin("org.jetbrains.scala" % "sbt-ide-settings" % "1.1.1") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.1") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") +// serverless +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6") diff --git a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala index caa3522183..90035213f6 100644 --- a/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala +++ b/server/http4s-server/src/main/scala/sttp/tapir/server/http4s/Http4sServerInterpreter.scala @@ -16,6 +16,7 @@ import sttp.capabilities.WebSockets import sttp.capabilities.fs2.Fs2Streams import sttp.model.{Header => SttpHeader} import sttp.tapir.Endpoint +import sttp.tapir.integ.cats.CatsMonadError import sttp.tapir.model.ServerResponse import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} diff --git a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala index 9d954299e1..846173d52d 100644 --- a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala +++ b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala @@ -12,6 +12,7 @@ import sttp.capabilities.fs2.Fs2Streams import sttp.client3._ import sttp.model.sse.ServerSentEvent import sttp.tapir._ +import sttp.tapir.integ.cats.CatsMonadError import sttp.tapir.server.tests.{ CreateServerTest, ServerAuthenticationTests, diff --git a/serverless/aws/examples/src/main/resources/event.json b/serverless/aws/examples/src/main/resources/event.json new file mode 100644 index 0000000000..3bec355a84 --- /dev/null +++ b/serverless/aws/examples/src/main/resources/event.json @@ -0,0 +1,41 @@ +{ + "version": "2.0", + "routeKey": "GET /hello", + "rawPath": "/hello", + "rawQueryString": "", + "headers": { + "accept": "*/*", + "content-length": "3", + "content-type": "application/x-www-form-urlencoded", + "host": "9abc9.execute-api.eu-central-1.amazonaws.com", + "user-agent": "curl/7.64.1", + "x-amzn-trace-id": "Root=1-60250d19-7182a3ff0e9dffb334e2bf74", + "x-forwarded-for": "78.11.177.136", + "x-forwarded-port": "443", + "x-forwarded-proto": "https" + }, + "queryStringParameters": { + "a": "123" + }, + "requestContext": { + "accountId": "1234567890", + "apiId": "9abc9", + "domainName": "9abc9.execute-api.eu-central-1.amazonaws.com", + "domainPrefix": "9abc9", + "http": { + "method": "GET", + "path": "/hello", + "protocol": "HTTP/1.1", + "sourceIp": "78.11.177.136", + "userAgent": "curl/7.64.1" + }, + "requestId": "ak78CiA8FiAEPWQ=", + "routeKey": "POST /hello", + "stage": "$default", + "time": "11/Feb/2021:10:55:21 +0000", + "timeEpoch": 1613040921706 + }, + "pathParameters": {}, + "body": "OTg3", + "isBase64Encoded": true +} \ No newline at end of file diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala new file mode 100644 index 0000000000..cbb134fd42 --- /dev/null +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala @@ -0,0 +1,42 @@ +package sttp.tapir.serverless.aws.examples + +import cats.effect.IO +import cats.syntax.all._ +import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler} +import io.circe.Printer +import io.circe.generic.auto._ +import io.circe.syntax._ +import sttp.tapir._ +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.serverless.aws.examples.HelloHandler.helloEndpoint +import sttp.tapir.serverless.aws.lambda._ + +import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets + +class HelloHandler extends RequestStreamHandler { + + override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() + + val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) + + val ctx = LambdaRuntimeContext(input, output, context) + + val result: IO[Unit] = route(ctx) + .map { awsRes => + val writer = new BufferedWriter(new OutputStreamWriter(ctx.output, StandardCharsets.UTF_8)) + writer.write(Printer.noSpaces.print(awsRes.asJson)) + writer.flush() + } + + result.unsafeRunSync() + } +} + +object HelloHandler { + val helloEndpoint: ServerEndpoint[Unit, Unit, String, Any, IO] = endpoint.get + .in("hello") + .out(stringBody) + .serverLogic(_ => IO.pure("Hello!".asRight[Unit])) +} diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala new file mode 100644 index 0000000000..6ed107a7cd --- /dev/null +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala @@ -0,0 +1,22 @@ +package sttp.tapir.serverless.aws.examples + +import io.circe.syntax._ +import sttp.tapir.serverless.aws.sam.{AwsSamInterpreter, AwsSamOptions, AwsSamTemplateEncoders, Printer} + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} + +object HelloSam extends App { + val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/examples/src/main/resources/template.yaml" + + implicit val samOptions: AwsSamOptions = AwsSamOptions("hello", "???") + + val samTemplate = new AwsSamInterpreter().apply(List(HelloHandler.helloEndpoint.endpoint)) + + val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain) + .pretty(samTemplate.asJson(AwsSamTemplateEncoders.encoderSamTemplate)) + + println(yaml) + + Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsBodyListener.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsBodyListener.scala new file mode 100644 index 0000000000..2ab0bf64bb --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsBodyListener.scala @@ -0,0 +1,11 @@ +package sttp.tapir.serverless.aws.lambda + +import sttp.monad.MonadError +import sttp.monad.syntax._ +import sttp.tapir.server.interpreter.BodyListener + +import scala.util.{Success, Try} + +private[lambda] class AwsBodyListener[F[_]: MonadError] extends BodyListener[F, String] { + override def onComplete(body: String)(cb: Try[Unit] => F[Unit]): F[String] = cb(Success(())).map(_ => body) +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala new file mode 100644 index 0000000000..23c5bfec05 --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsRequestBody.scala @@ -0,0 +1,34 @@ +package sttp.tapir.serverless.aws.lambda + +import sttp.capabilities +import sttp.monad.MonadError +import sttp.monad.syntax._ +import sttp.tapir.RawBodyType +import sttp.tapir.internal.NoStreams +import sttp.tapir.server.interpreter.{RawValue, RequestBody} + +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.util.Base64 + +private[lambda] class AwsRequestBody[F[_]: MonadError](request: AwsRequest) extends RequestBody[F, Nothing] { + override val streams: capabilities.Streams[Nothing] = NoStreams + + override def toRaw[R](bodyType: RawBodyType[R]): F[RawValue[R]] = { + val decoded = + if (request.isBase64Encoded) Left(Base64.getDecoder.decode(request.body.getOrElse(""))) else Right(request.body.getOrElse("")) + + def asByteArray: Array[Byte] = decoded.fold(identity[Array[Byte]], _.getBytes()) + + RawValue(bodyType match { + case RawBodyType.StringBody(charset) => decoded.fold(new String(_, charset), identity[String]) + case RawBodyType.ByteArrayBody => asByteArray + case RawBodyType.ByteBufferBody => ByteBuffer.wrap(asByteArray) + case RawBodyType.InputStreamBody => new ByteArrayInputStream(asByteArray) + case RawBodyType.FileBody => throw new UnsupportedOperationException + case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException + }).asInstanceOf[RawValue[R]].unit + } + + override def toStream(): streams.BinaryStream = throw new UnsupportedOperationException +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala new file mode 100644 index 0000000000..223e3636d0 --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -0,0 +1,61 @@ +package sttp.tapir.serverless.aws.lambda + +import cats.data.Kleisli +import cats.effect.Sync +import io.circe.generic.auto._ +import io.circe.parser.decode +import sttp.model.StatusCode +import sttp.monad.syntax._ +import sttp.tapir.Endpoint +import sttp.tapir.integ.cats.CatsMonadError +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} + +import java.nio.charset.StandardCharsets + +trait AwsServerInterpreter { + def toRoute[I, E, O, F[_]](e: Endpoint[I, E, O, Any])( + logic: I => F[Either[E, O]] + )(implicit serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = toRoute(e.serverLogic(logic)) + + def toRoute[I, E, O, F[_]](se: ServerEndpoint[I, E, O, Any, F])(implicit serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = + toRoute(List(se)) + + def toRoute[F[_]](ses: List[ServerEndpoint[_, _, _, Any, F]])(implicit serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = { + implicit val monad: CatsMonadError[F] = new CatsMonadError[F] + implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] + + Kleisli { context: LambdaRuntimeContext => + val json = new String(context.input.readAllBytes(), StandardCharsets.UTF_8) + + decode[AwsRequest](json) match { + case Left(error) => AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, error.getMessage).unit + case Right(awsReq) => + implicit val monad: CatsMonadError[F] = new CatsMonadError[F] + implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] + val serverRequest = new AwsServerRequest(awsReq) + val interpreter = new ServerInterpreter[Any, F, String, Nothing]( + new AwsRequestBody[F](awsReq), + AwsToResponseBody, + serverOptions.interceptors, + deleteFile = _ => ().unit // no file support + ) + + interpreter.apply(serverRequest, ses).map { + case None => AwsResponse(Nil, isBase64Encoded = false, StatusCode.NotFound.code, Map.empty, "") + case Some(res) => + val cookies = res.cookies.collect { case Right(cookie) => cookie.value }.toList + val headers = res.headers.map(h => h.name -> h.value).toMap + AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) + } +// .map { awsRes => +// val writer = new BufferedWriter(new OutputStreamWriter(context.output, StandardCharsets.UTF_8)) +// writer.write(Printer.noSpaces.print(awsRes.asJson)) +// writer.flush() +// } + } + } + } +} + +object AwsServerInterpreter extends AwsServerInterpreter diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala new file mode 100644 index 0000000000..de40b57c40 --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala @@ -0,0 +1,32 @@ +package sttp.tapir.serverless.aws.lambda + +import sttp.tapir.server.interceptor.Interceptor +import sttp.tapir.server.interceptor.content.UnsupportedMediaTypeInterceptor +import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DecodeFailureInterceptor, DefaultDecodeFailureHandler} +import sttp.tapir.server.interceptor.exception.{DefaultExceptionHandler, ExceptionHandler, ExceptionInterceptor} +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor + +case class AwsServerOptions[F[_]](interceptors: List[Interceptor[F, String]]) { + def prependInterceptor(i: Interceptor[F, String]): AwsServerOptions[F] = copy(interceptors = i :: interceptors) + def appendInterceptor(i: Interceptor[F, String]): AwsServerOptions[F] = copy(interceptors = interceptors :+ i) +} + +object AwsServerOptions { + def customInterceptors[F[_]]( + metricsInterceptor: Option[MetricsRequestInterceptor[F, String]] = None, + exceptionHandler: Option[ExceptionHandler] = Some(DefaultExceptionHandler), + // todo log serverLog: Option[ServerLog[Context => F[Unit]]] = None, + additionalInterceptors: List[Interceptor[F, String]] = Nil, + unsupportedMediaTypeInterceptor: Option[UnsupportedMediaTypeInterceptor[F, String]] = Some( + new UnsupportedMediaTypeInterceptor[F, String]() + ), + decodeFailureHandler: DecodeFailureHandler = DefaultDecodeFailureHandler.handler + ): AwsServerOptions[F] = AwsServerOptions( + metricsInterceptor.toList ++ + exceptionHandler.map(new ExceptionInterceptor[F, String](_)).toList ++ + // todo log + additionalInterceptors ++ + unsupportedMediaTypeInterceptor.toList ++ + List(new DecodeFailureInterceptor[F, String](decodeFailureHandler)) + ) +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala new file mode 100644 index 0000000000..5a6cf2e353 --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala @@ -0,0 +1,24 @@ +package sttp.tapir.serverless.aws.lambda + +import sttp.model.{Header, Method, QueryParams, Uri} +import sttp.tapir.model.{ConnectionInfo, ServerRequest} + +import java.net.{InetSocketAddress, URLDecoder} + +private[lambda] class AwsServerRequest(request: AwsRequest) extends ServerRequest { + private val sttpUri: Uri = { + val queryString = if (request.rawQueryString.nonEmpty) "?" + request.rawQueryString else "" + Uri.unsafeParse(s"$protocol://${request.requestContext.domainName}${request.rawPath}$queryString") + } + + override def protocol: String = request.headers.getOrElse("x-forwarded-proto", "http") + override def connectionInfo: ConnectionInfo = + ConnectionInfo(None, Some(InetSocketAddress.createUnresolved(request.requestContext.http.sourceIp, 80)), None) + override def underlying: Any = request + override def pathSegments: List[String] = + request.rawPath.dropWhile(_ == '/').split("/").toList.map(value => URLDecoder.decode(value, "UTF-8")) + override def queryParameters: QueryParams = sttpUri.params + override def method: Method = Method.unsafeApply(request.requestContext.http.method) + override def uri: Uri = sttpUri + override def headers: Seq[Header] = request.headers.map { case (n, v) => Header(n, v) }.toSeq +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala new file mode 100644 index 0000000000..ff5eb6d04e --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala @@ -0,0 +1,56 @@ +package sttp.tapir.serverless.aws.lambda + +import sttp.capabilities +import sttp.model.HasHeaders +import sttp.tapir.internal.NoStreams +import sttp.tapir.server.interpreter.ToResponseBody +import sttp.tapir.{CodecFormat, RawBodyType, WebSocketBodyOutput} + +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.util.Base64 + +private[lambda] object AwsToResponseBody extends ToResponseBody[String, Nothing] { + override val streams: capabilities.Streams[Nothing] = NoStreams + + override def fromRawValue[R](v: R, headers: HasHeaders, format: CodecFormat, bodyType: RawBodyType[R]): String = bodyType match { + case RawBodyType.StringBody(charset) => + val str = v.asInstanceOf[String] + Base64.getEncoder.encodeToString(str.getBytes(charset)) + + case RawBodyType.ByteArrayBody => + val bytes = v.asInstanceOf[Array[Byte]] + Base64.getEncoder.encodeToString(bytes) + + case RawBodyType.ByteBufferBody => + val byteBuffer = v.asInstanceOf[ByteBuffer] + Base64.getEncoder.encodeToString(safeRead(byteBuffer)) + + case RawBodyType.InputStreamBody => + val stream = v.asInstanceOf[InputStream] + Base64.getEncoder.encodeToString(stream.readAllBytes()) + + case RawBodyType.FileBody => throw new UnsupportedOperationException + + case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException + } + + private def safeRead(byteBuffer: ByteBuffer): Array[Byte] = { + if (byteBuffer.hasArray) { + byteBuffer.array() + } else { + val array = new Array[Byte](byteBuffer.remaining()) + byteBuffer.get(array) + array + } + } + + override def fromStreamValue(v: streams.BinaryStream, headers: HasHeaders, format: CodecFormat, charset: Option[Charset]): String = + throw new UnsupportedOperationException + + override def fromWebSocketPipe[REQ, RESP]( + pipe: streams.Pipe[REQ, RESP], + o: WebSocketBodyOutput[streams.Pipe[REQ, RESP], REQ, RESP, _, Nothing] + ): String = throw new UnsupportedOperationException +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala new file mode 100644 index 0000000000..6060ef9324 --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala @@ -0,0 +1,20 @@ +package sttp.tapir.serverless.aws.lambda + +import com.amazonaws.services.lambda.runtime.Context + +import java.io.{InputStream, OutputStream} + +case class AwsRequest( + rawPath: String, + rawQueryString: String, + headers: Map[String, String], + requestContext: AwsRequestContext, + body: Option[String], + isBase64Encoded: Boolean +) +case class AwsRequestContext(domainName: String, http: AwsHttp) +case class AwsHttp(method: String, path: String, protocol: String, sourceIp: String, userAgent: String) + +case class AwsResponse(cookies: List[String], isBase64Encoded: Boolean, statusCode: Int, headers: Map[String, String], body: String) + +case class LambdaRuntimeContext(input: InputStream, output: OutputStream, context: Context) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala new file mode 100644 index 0000000000..e6377c82ad --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala @@ -0,0 +1,7 @@ +package sttp.tapir.serverless.aws + +import cats.data.Kleisli + +package object lambda { + type Route[F[_]] = Kleisli[F, LambdaRuntimeContext, AwsResponse] +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala new file mode 100644 index 0000000000..8b435c0e59 --- /dev/null +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala @@ -0,0 +1,67 @@ +package sttp.tapir.serverless.aws.sam + +import sttp.model.Method +import sttp.tapir.internal._ +import sttp.tapir.{Endpoint, EndpointInput} + +import scala.collection.immutable.ListMap + +class AwsSamInterpreter { + type AnyEndpoint = Endpoint[_, _, _, _] + + def apply(es: List[AnyEndpoint])(implicit options: AwsSamOptions): SamTemplate = { + val functionName = options.namePrefix + "Function" + val httpApiName = options.namePrefix + "HttpApi" + + val apiEvents = es.map(endpointNameMethodAndPath).map { case (name, method, path) => + name -> FunctionHttpApiEvent( + FunctionHttpApiEventProperties(s"!Ref $httpApiName", method.map(_.method).getOrElse("ANY"), path, options.timeout.toMillis) + ) + } + + SamTemplate( + Resources = ListMap( + functionName -> FunctionResource( + FunctionProperties(options.imageUri, options.timeout.toSeconds, options.memorySize, ListMap.from(apiEvents)) + ), + httpApiName -> HttpResource(HttpProperties("$default")) + ), + Outputs = ListMap( + (options.namePrefix + "Url") -> Output( + "Base URL of your endpoints", + ListMap("Fn::Sub" -> ("https://${" + httpApiName + "}.execute-api.${AWS::Region}.${AWS::URLSuffix}")) + ) + ) + ) + } + + private def endpointNameMethodAndPath(e: AnyEndpoint): (String, Option[Method], String) = { + val pathComponents = e.input + .asVectorOfBasicInputs() + .collect { + case EndpointInput.PathCapture(name, _, _) => Left(name) + case EndpointInput.FixedPath(s, _, _) => Right(s) + } + .foldLeft((Vector.empty[Either[String, String]], 0)) { case ((acc, c), component) => + component match { + case Left(None) => (acc :+ Left(s"param$c"), c + 1) + case Left(Some(p)) => (acc :+ Left(p), c) + case Right(p) => (acc :+ Right(p), c) + } + } + ._1 + + val method = e.httpMethod + + val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map(_.fold(identity, identity)) + val name = (method.map(_.method.toLowerCase).getOrElse("any").capitalize +: nameComponents.map(_.toLowerCase.capitalize)).mkString + + val idComponents = pathComponents.map { + case Left(s) => s"{$s}" + case Right(s) => s + } + + (name, method, "/" + idComponents.mkString("/")) + } + +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala new file mode 100644 index 0000000000..c67b8ff70f --- /dev/null +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala @@ -0,0 +1,10 @@ +package sttp.tapir.serverless.aws.sam + +import scala.concurrent.duration.{DurationInt, FiniteDuration} + +case class AwsSamOptions( + namePrefix: String, + imageUri: String, + timeout: FiniteDuration = 10.seconds, + memorySize: Int = 256 +) diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala new file mode 100644 index 0000000000..f5d51fd328 --- /dev/null +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala @@ -0,0 +1,37 @@ +package sttp.tapir.serverless.aws.sam + +import io.circe.generic.semiauto.deriveEncoder +import io.circe.syntax.EncoderOps +import io.circe.{Encoder, Json} +import scala.collection.immutable.ListMap + +object AwsSamTemplateEncoders { + implicit def encodeListMap[V: Encoder]: Encoder[ListMap[String, V]] = { case m: ListMap[String, V] => + val properties = m.view.mapValues(v => implicitly[Encoder[V]].apply(v)).toList + Json.obj(properties: _*) + } + + implicit val encoderOutput: Encoder[Output] = deriveEncoder[Output] + implicit val encoderFunctionHttpApiEventProperties: Encoder[FunctionHttpApiEventProperties] = + deriveEncoder[FunctionHttpApiEventProperties] + implicit val encoderFunctionHttpApiEvent: Encoder[FunctionHttpApiEvent] = { + val encoder = deriveEncoder[FunctionHttpApiEvent] + e => Json.fromJsonObject(encoder(e).asJson.asObject.get.add("Type", Json.fromString("HttpApi"))) + } + + implicit val encoderHttpProperties: Encoder[HttpProperties] = deriveEncoder[HttpProperties] + implicit val encoderFunctionProperties: Encoder[FunctionProperties] = deriveEncoder[FunctionProperties] + implicit val encoderProperties: Encoder[Properties] = { + case v: HttpProperties => v.asJson + case v: FunctionProperties => v.asJson + } + + implicit val encoderHttpResource: Encoder[HttpResource] = deriveEncoder[HttpResource] + implicit val encoderFunctionResource: Encoder[FunctionResource] = deriveEncoder[FunctionResource] + implicit val encoderResource: Encoder[Resource] = { + case v: HttpResource => Json.fromJsonObject(v.asJson.asObject.get.add("Type", Json.fromString("AWS::Serverless::HttpApi"))) + case v: FunctionResource => Json.fromJsonObject(v.asJson.asObject.get.add("Type", Json.fromString("AWS::Serverless::Function"))) + } + + implicit val encoderSamTemplate: Encoder[SamTemplate] = deriveEncoder[SamTemplate] +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/Printer.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/Printer.scala new file mode 100644 index 0000000000..4f32fa3705 --- /dev/null +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/Printer.scala @@ -0,0 +1,167 @@ +package sttp.tapir.serverless.aws.sam + +import Printer._ +import io.circe._ +import java.io.StringWriter +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.emitter.Emitter +import org.yaml.snakeyaml.nodes._ +import org.yaml.snakeyaml.resolver.Resolver +import org.yaml.snakeyaml.serializer.Serializer +import scala.collection.JavaConverters._ + +// modified stringNode to handle !Ref tags +final case class Printer( + preserveOrder: Boolean = false, + dropNullKeys: Boolean = false, + indent: Int = 2, + maxScalarWidth: Int = 80, + splitLines: Boolean = true, + indicatorIndent: Int = 0, + tags: Map[String, String] = Map.empty, + sequenceStyle: FlowStyle = FlowStyle.Block, + mappingStyle: FlowStyle = FlowStyle.Block, + stringStyle: StringStyle = StringStyle.Plain, + lineBreak: LineBreak = LineBreak.Unix, + explicitStart: Boolean = false, + explicitEnd: Boolean = false, + version: YamlVersion = YamlVersion.Auto +) { + + def pretty(json: Json): String = { + val rootTag = yamlTag(json) + val writer = new StringWriter() + val serializer = new Serializer(new Emitter(writer, options), new Resolver, options, rootTag) + serializer.open() + serializer.serialize(jsonToYaml(json)) + serializer.close() + writer.toString + } + + private lazy val options = { + val options = new DumperOptions() + options.setIndent(indent) + options.setWidth(maxScalarWidth) + options.setSplitLines(splitLines) + options.setIndicatorIndent(indicatorIndent) + options.setTags(tags.asJava) + options.setDefaultScalarStyle(StringStyle.toScalarStyle(stringStyle)) + options.setLineBreak(lineBreak match { + case LineBreak.Unix => DumperOptions.LineBreak.UNIX + case LineBreak.Windows => DumperOptions.LineBreak.WIN + case LineBreak.Mac => DumperOptions.LineBreak.MAC + }) + options.setVersion(version match { + case YamlVersion.Auto => null + case YamlVersion.Yaml1_0 => DumperOptions.Version.V1_0 + case YamlVersion.Yaml1_1 => DumperOptions.Version.V1_1 + }) + options.setExplicitStart(explicitStart) + options.setExplicitEnd(explicitEnd) + options + } + + private def isBad(s: String): Boolean = s.indexOf('\u0085') >= 0 || s.indexOf('\ufeff') >= 0 + + private def scalarStyle(value: String): DumperOptions.ScalarStyle = + if (isBad(value)) DumperOptions.ScalarStyle.DOUBLE_QUOTED else DumperOptions.ScalarStyle.PLAIN + + private def stringScalarStyle(value: String): DumperOptions.ScalarStyle = + if (isBad(value)) DumperOptions.ScalarStyle.DOUBLE_QUOTED else StringStyle.toScalarStyle(stringStyle) + + private def scalarNode(tag: Tag, value: String) = new ScalarNode(tag, value, null, null, scalarStyle(value)) + private def stringNode(value: String) = if (value.startsWith("!Ref ")) { + new ScalarNode(new Tag("!Ref"), value.substring(5), null, null, stringScalarStyle(value)) + } else { + new ScalarNode(Tag.STR, value, null, null, stringScalarStyle(value)) + } + + private def keyNode(value: String) = new ScalarNode(Tag.STR, value, null, null, scalarStyle(value)) + + private def jsonToYaml(json: Json): Node = { + + def convertObject(obj: JsonObject) = { + val fields = if (preserveOrder) obj.keys else obj.keys.toSet + val m = obj.toMap + val childNodes = fields.flatMap { key => + val value = m(key) + if (!dropNullKeys || !value.isNull) Some(new NodeTuple(keyNode(key), jsonToYaml(value))) + else None + } + new MappingNode( + Tag.MAP, + childNodes.toList.asJava, + if (mappingStyle == FlowStyle.Flow) DumperOptions.FlowStyle.FLOW else DumperOptions.FlowStyle.BLOCK + ) + } + + json.fold( + scalarNode(Tag.NULL, "null"), + bool => scalarNode(Tag.BOOL, bool.toString), + number => scalarNode(numberTag(number), number.toString), + str => stringNode(str), + arr => + new SequenceNode( + Tag.SEQ, + arr.map(jsonToYaml).asJava, + if (sequenceStyle == FlowStyle.Flow) DumperOptions.FlowStyle.FLOW else DumperOptions.FlowStyle.BLOCK + ), + obj => convertObject(obj) + ) + } +} + +object Printer { + + val spaces2 = Printer() + val spaces4 = Printer(indent = 4) + + sealed trait FlowStyle + object FlowStyle { + case object Flow extends FlowStyle + case object Block extends FlowStyle + } + + sealed trait StringStyle + object StringStyle { + case object Plain extends StringStyle + case object DoubleQuoted extends StringStyle + case object SingleQuoted extends StringStyle + case object Literal extends StringStyle + case object Folded extends StringStyle + + def toScalarStyle(style: StringStyle): DumperOptions.ScalarStyle = style match { + case StringStyle.Plain => DumperOptions.ScalarStyle.PLAIN + case StringStyle.DoubleQuoted => DumperOptions.ScalarStyle.DOUBLE_QUOTED + case StringStyle.SingleQuoted => DumperOptions.ScalarStyle.SINGLE_QUOTED + case StringStyle.Literal => DumperOptions.ScalarStyle.LITERAL + case StringStyle.Folded => DumperOptions.ScalarStyle.FOLDED + } + } + + sealed trait LineBreak + object LineBreak { + case object Unix extends LineBreak + case object Windows extends LineBreak + case object Mac extends LineBreak + } + + sealed trait YamlVersion + object YamlVersion { + case object Yaml1_0 extends YamlVersion + case object Yaml1_1 extends YamlVersion + case object Auto extends YamlVersion + } + + private def yamlTag(json: Json) = json.fold( + Tag.NULL, + _ => Tag.BOOL, + number => numberTag(number), + _ => Tag.STR, + _ => Tag.SEQ, + _ => Tag.MAP + ) + + private def numberTag(number: JsonNumber): Tag = + if (number.toString.contains(".")) Tag.FLOAT else Tag.INT +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala new file mode 100644 index 0000000000..ae7742add7 --- /dev/null +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala @@ -0,0 +1,38 @@ +package sttp.tapir.serverless.aws.sam + +import scala.collection.immutable.ListMap + +case class SamTemplate( + AWSTemplateFormatVersion: String = "2010-09-09", + Transform: String = "AWS::Serverless-2016-10-31", + Resources: ListMap[String, Resource], + Outputs: ListMap[String, Output] +) + +trait Resource { + def Properties: Properties +} +case class FunctionResource(Properties: FunctionProperties) extends Resource +case class HttpResource(Properties: HttpProperties) extends Resource + +trait Properties +case class FunctionProperties( + ImageUri: String, + Timeout: Long, + MemorySize: Int, + Events: ListMap[String, FunctionHttpApiEvent], + PackageType: String = "Image" +) extends Properties +case class HttpProperties(StageName: String) extends Properties + +case class FunctionHttpApiEvent(Properties: FunctionHttpApiEventProperties) + +case class FunctionHttpApiEventProperties( + ApiId: String, + Method: String, + Path: String, + TimeoutInMillis: Long, + PayloadFormatVersion: String = "2.0" +) + +case class Output(Description: String, Value: ListMap[String, String]) From 83824b389c71411eb6dfbde9df43dae7e0c4869a Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 10 May 2021 16:55:13 +0200 Subject: [PATCH 02/35] tests wip --- build.sbt | 28 ++++++++-- project/plugins.sbt | 1 + .../aws/examples/HelloHandler.scala | 23 +++++--- .../serverless/aws/examples/HelloSam.scala | 11 ++-- .../aws/lambda/AwsServerInterpreter.scala | 54 ++++++++----------- .../tapir/serverless/aws/lambda/model.scala | 2 +- .../tapir/serverless/aws/lambda/package.scala | 2 +- .../aws/lambda/AwsLambdaHttpTest.scala | 18 +++++++ .../aws/lambda/AwsLambdaHttpTestHandler.scala | 31 +++++++++++ .../lambda/AwsLambdaHttpTestSamTemplate.scala | 25 +++++++++ .../AwsLambdaHttpTestServerInterpreter.scala | 37 +++++++++++++ .../aws/sam/AwsSamInterpreter.scala | 15 +++++- .../serverless/aws/sam/AwsSamOptions.scala | 6 ++- .../aws/sam/AwsSamTemplateEncoders.scala | 9 ++-- .../sttp/tapir/serverless/aws/sam/model.scala | 25 +++++++-- .../scala/sttp/tapir/tests/TestSuite.scala | 2 +- 16 files changed, 230 insertions(+), 59 deletions(-) create mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala create mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala create mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala create mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala diff --git a/build.sbt b/build.sbt index 7929699300..bbb6c3e6a3 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,7 @@ import java.net.URL import com.softwaremill.SbtSoftwareMillBrowserTestJS._ import com.softwaremill.UpdateVersionInDocs +import com.typesafe.sbt.packager.docker.ExecCmd import sbt.Reference.display import sbt.internal.ProjectMatrix @@ -870,10 +871,12 @@ lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambd name := "tapir-aws-lambda", libraryDependencies ++= Seq( "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "1.0.0" - ) + ), + assembly / assemblyJarName := "tapir-aws-lambda.jar", + Project.inConfig(Test)(baseAssemblySettings) ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core, cats, circeJson, tests) + .dependsOn(core, cats, circeJson, awsSam, serverTests % Test) lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .settings(commonJvmSettings) @@ -888,10 +891,27 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .dependsOn(core) lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) - .enablePlugins(DockerPlugin) + .enablePlugins(JavaAppPackaging, DockerPlugin) .settings(commonJvmSettings) .settings( - name := "tapir-aws-examples" + libraryDependencies += "com.amazonaws" % "aws-java-sdk-lambda" % "1.11.1014" + ) + .settings( + assembly / assemblyJarName := "examples.jar", + version := "1.0.0", + name := "tapir-aws-examples", + packageName in Docker := "tapir-aws-examples", + dockerBaseImage := "public.ecr.aws/lambda/java:11", + daemonUser in Docker := "daemon", + dockerUpdateLatest := true, + dockerCmd := List("com.softwaremill.sttp.tapir.serverless.aws.examples.HelloHandler::handleRequest"), +// dockerCommands := dockerCommands.value.filterNot { +// case ExecCmd("ENTRYPOINT", _) => true +// case _ => false +// }, + // https://hub.docker.com/r/amazon/aws-lambda-java + defaultLinuxInstallLocation in Docker := "/var/task", + dockerRepository := Some("localhost:5000") ) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(awsLambda, awsSam) diff --git a/project/plugins.sbt b/project/plugins.sbt index 1da0515621..225d98acb5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -12,3 +12,4 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.1") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") // serverless addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala index cbb134fd42..e19269114b 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala @@ -5,6 +5,7 @@ import cats.syntax.all._ import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler} import io.circe.Printer import io.circe.generic.auto._ +import io.circe.parser.decode import io.circe.syntax._ import sttp.tapir._ import sttp.tapir.server.ServerEndpoint @@ -19,13 +20,15 @@ class HelloHandler extends RequestStreamHandler { override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() - val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) + val route: Route[IO] = AwsServerInterpreter.toRoute(allTestE) - val ctx = LambdaRuntimeContext(input, output, context) + val json = new String(input.readAllBytes(), StandardCharsets.UTF_8) - val result: IO[Unit] = route(ctx) + val awsRequest = decode[AwsRequest](json).getOrElse(throw new Exception) + + val result: IO[Unit] = route(awsRequest) .map { awsRes => - val writer = new BufferedWriter(new OutputStreamWriter(ctx.output, StandardCharsets.UTF_8)) + val writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)) writer.write(Printer.noSpaces.print(awsRes.asJson)) writer.flush() } @@ -35,8 +38,14 @@ class HelloHandler extends RequestStreamHandler { } object HelloHandler { - val helloEndpoint: ServerEndpoint[Unit, Unit, String, Any, IO] = endpoint.get + import io.circe.generic.auto._ + import sttp.tapir.generic.auto._ + import sttp.tapir.json.circe.jsonBody + + case class HelloResponse(msg: String) + + val helloEndpoint: ServerEndpoint[Unit, Unit, HelloResponse, Any, IO] = endpoint.get .in("hello") - .out(stringBody) - .serverLogic(_ => IO.pure("Hello!".asRight[Unit])) + .out(jsonBody[HelloResponse]) + .serverLogic(_ => IO.pure(HelloResponse("Hello!").asRight[Unit])) } diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala index 6ed107a7cd..5b463e5ac2 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala @@ -1,22 +1,23 @@ package sttp.tapir.serverless.aws.examples import io.circe.syntax._ -import sttp.tapir.serverless.aws.sam.{AwsSamInterpreter, AwsSamOptions, AwsSamTemplateEncoders, Printer} +import sttp.tapir.serverless.aws.sam._ import java.nio.charset.StandardCharsets import java.nio.file.{Files, Paths} object HelloSam extends App { - val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/examples/src/main/resources/template.yaml" + val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/examples/template.yaml" - implicit val samOptions: AwsSamOptions = AwsSamOptions("hello", "???") + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "hello", + source = CodeSource("java11", "target/jvm-2.13/examples.jar", "sttp.tapir.serverless.aws.examples.HelloHandler::handleRequest") + ) val samTemplate = new AwsSamInterpreter().apply(List(HelloHandler.helloEndpoint.endpoint)) val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain) .pretty(samTemplate.asJson(AwsSamTemplateEncoders.encoderSamTemplate)) - println(yaml) - Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index 223e3636d0..a467e0f5fd 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -2,8 +2,6 @@ package sttp.tapir.serverless.aws.lambda import cats.data.Kleisli import cats.effect.Sync -import io.circe.generic.auto._ -import io.circe.parser.decode import sttp.model.StatusCode import sttp.monad.syntax._ import sttp.tapir.Endpoint @@ -11,7 +9,7 @@ import sttp.tapir.integ.cats.CatsMonadError import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} -import java.nio.charset.StandardCharsets +import scala.reflect.ClassTag trait AwsServerInterpreter { def toRoute[I, E, O, F[_]](e: Endpoint[I, E, O, Any])( @@ -21,38 +19,32 @@ trait AwsServerInterpreter { def toRoute[I, E, O, F[_]](se: ServerEndpoint[I, E, O, Any, F])(implicit serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = toRoute(List(se)) + def toRouteRecoverErrors[I, E, O, F[_]](e: Endpoint[I, E, O, Any])( + logic: I => F[O] + )(implicit eIsThrowable: E <:< Throwable, eClassTag: ClassTag[E], serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = + toRoute(e.serverLogicRecoverErrors(logic)) + def toRoute[F[_]](ses: List[ServerEndpoint[_, _, _, Any, F]])(implicit serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = { implicit val monad: CatsMonadError[F] = new CatsMonadError[F] implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] - Kleisli { context: LambdaRuntimeContext => - val json = new String(context.input.readAllBytes(), StandardCharsets.UTF_8) - - decode[AwsRequest](json) match { - case Left(error) => AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, error.getMessage).unit - case Right(awsReq) => - implicit val monad: CatsMonadError[F] = new CatsMonadError[F] - implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] - val serverRequest = new AwsServerRequest(awsReq) - val interpreter = new ServerInterpreter[Any, F, String, Nothing]( - new AwsRequestBody[F](awsReq), - AwsToResponseBody, - serverOptions.interceptors, - deleteFile = _ => ().unit // no file support - ) - - interpreter.apply(serverRequest, ses).map { - case None => AwsResponse(Nil, isBase64Encoded = false, StatusCode.NotFound.code, Map.empty, "") - case Some(res) => - val cookies = res.cookies.collect { case Right(cookie) => cookie.value }.toList - val headers = res.headers.map(h => h.name -> h.value).toMap - AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) - } -// .map { awsRes => -// val writer = new BufferedWriter(new OutputStreamWriter(context.output, StandardCharsets.UTF_8)) -// writer.write(Printer.noSpaces.print(awsRes.asJson)) -// writer.flush() -// } + Kleisli { request: AwsRequest => + implicit val monad: CatsMonadError[F] = new CatsMonadError[F] + implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] + val serverRequest = new AwsServerRequest(request) + val interpreter = new ServerInterpreter[Any, F, String, Nothing]( + new AwsRequestBody[F](request), + AwsToResponseBody, + serverOptions.interceptors, + deleteFile = _ => ().unit // no file support + ) + + interpreter.apply(serverRequest, ses).map { + case None => AwsResponse(Nil, isBase64Encoded = false, StatusCode.NotFound.code, Map.empty, "") + case Some(res) => + val cookies = res.cookies.collect { case Right(cookie) => cookie.value }.toList + val headers = res.headers.map(h => h.name -> h.value).toMap + AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) } } } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala index 6060ef9324..c81c150bd3 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala @@ -12,7 +12,7 @@ case class AwsRequest( body: Option[String], isBase64Encoded: Boolean ) -case class AwsRequestContext(domainName: String, http: AwsHttp) +case class AwsRequestContext(domainName: Option[String], http: AwsHttp) case class AwsHttp(method: String, path: String, protocol: String, sourceIp: String, userAgent: String) case class AwsResponse(cookies: List[String], isBase64Encoded: Boolean, statusCode: Int, headers: Map[String, String], body: String) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala index e6377c82ad..be6d6e7a87 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala @@ -3,5 +3,5 @@ package sttp.tapir.serverless.aws import cats.data.Kleisli package object lambda { - type Route[F[_]] = Kleisli[F, LambdaRuntimeContext, AwsResponse] + type Route[F[_]] = Kleisli[F, AwsRequest, AwsResponse] } diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala new file mode 100644 index 0000000000..667093f2e0 --- /dev/null +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala @@ -0,0 +1,18 @@ +package sttp.tapir.serverless.aws.lambda + +import cats.effect.{IO, Resource} +import sttp.tapir.integ.cats.CatsMonadError +import sttp.tapir.server.tests.{CreateServerTest, ServerBasicTests, backendResource} +import sttp.tapir.tests.{Test, TestSuite} + +class AwsLambdaHttpTest extends TestSuite { + + override def tests: Resource[IO, List[Test]] = backendResource.map { backend => + implicit val m: CatsMonadError[IO] = new CatsMonadError[IO] + val interpreter = new AwsLambdaHttpTestServerInterpreter + val createServerTest = new CreateServerTest(interpreter) + val tests = new ServerBasicTests(backend, createServerTest, interpreter).tests() + + val handler = + } +} diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala new file mode 100644 index 0000000000..09561e2375 --- /dev/null +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala @@ -0,0 +1,31 @@ +package sttp.tapir.serverless.aws.lambda + +import cats.effect.IO +import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler} +import io.circe.Printer +import io.circe.generic.auto._ +import io.circe.parser.decode +import io.circe.syntax._ +import sttp.model.StatusCode +import sttp.tapir.server.ServerEndpoint + +import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets + +private[lambda] class AwsLambdaHttpTestHandler(eps: List[ServerEndpoint[_, _, _, Any, IO]]) extends RequestStreamHandler { + override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { + + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() + val route: Route[IO] = AwsServerInterpreter.toRoute(eps) + val json = new String(input.readAllBytes(), StandardCharsets.UTF_8) + + (decode[AwsRequest](json) match { + case Right(awsRequest) => route(awsRequest) + case Left(_) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, "")) + }).map { awsRes => + val writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)) + writer.write(Printer.noSpaces.print(awsRes.asJson)) + writer.flush() + }.unsafeRunSync() + } +} diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala new file mode 100644 index 0000000000..c09cb3d967 --- /dev/null +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala @@ -0,0 +1,25 @@ +package sttp.tapir.serverless.aws.lambda + +import io.circe.syntax._ +import sttp.tapir.Endpoint +import sttp.tapir.serverless.aws.sam._ +import sttp.tapir.tests._ + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} + +object AwsLambdaHttpTestSamTemplate extends App { + val eps: Set[Endpoint[_, _, _, _]] = allTestEndpoints + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "hello", + source = CodeSource("java11", "target/jvm-2.13/examples.jar", "sttp.tapir.serverless.aws.examples.HelloHandler::handleRequest") + ) + val samTemplate = new AwsSamInterpreter().apply(eps.toList) + + val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain) + .pretty(samTemplate.asJson(AwsSamTemplateEncoders.encoderSamTemplate)) + + val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/examples/template.yaml" + + Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) +} diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala new file mode 100644 index 0000000000..5a10b42500 --- /dev/null +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala @@ -0,0 +1,37 @@ +package sttp.tapir.serverless.aws.lambda + +import cats.data.NonEmptyList +import cats.effect.{IO, Resource} +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} +import sttp.tapir.server.interceptor.exception.DefaultExceptionHandler +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor +import sttp.tapir.server.tests.TestServerInterpreter +import sttp.tapir.tests.Port + +import scala.reflect.ClassTag + +class AwsLambdaHttpTestServerInterpreter extends TestServerInterpreter[IO, Any, Route[IO], String] { + override def route[I, E, O]( + e: ServerEndpoint[I, E, O, Any, IO], + decodeFailureHandler: Option[DecodeFailureHandler], + metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] + ): Route[IO] = { + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]( + metricsInterceptor = metricsInterceptor, + exceptionHandler = Some(DefaultExceptionHandler), + decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) + ) + AwsServerInterpreter.toRoute(e) + } + + override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit + eClassTag: ClassTag[E] + ): Route[IO] = { + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() + AwsServerInterpreter.toRouteRecoverErrors(e)(fn) + } + + override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = Resource.eval(IO.pure(3000)) +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala index 8b435c0e59..70492334d4 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala @@ -22,7 +22,19 @@ class AwsSamInterpreter { SamTemplate( Resources = ListMap( functionName -> FunctionResource( - FunctionProperties(options.imageUri, options.timeout.toSeconds, options.memorySize, ListMap.from(apiEvents)) + options.source match { + case ImageSource(imageUri) => + FunctionImageProperties(options.timeout.toSeconds, options.memorySize, ListMap.from(apiEvents), imageUri) + case cs @ CodeSource(_, _, _) => + FunctionCodeProperties( + options.timeout.toSeconds, + options.memorySize, + ListMap.from(apiEvents), + cs.runtime, + cs.codeUri, + cs.handler + ) + } ), httpApiName -> HttpResource(HttpProperties("$default")) ), @@ -63,5 +75,4 @@ class AwsSamInterpreter { (name, method, "/" + idComponents.mkString("/")) } - } diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala index c67b8ff70f..da5a38bc74 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala @@ -4,7 +4,11 @@ import scala.concurrent.duration.{DurationInt, FiniteDuration} case class AwsSamOptions( namePrefix: String, - imageUri: String, + source: FunctionSource, timeout: FiniteDuration = 10.seconds, memorySize: Int = 256 ) + +trait FunctionSource +case class ImageSource(imageUri: String) extends FunctionSource +case class CodeSource(runtime: String, codeUri: String, handler: String) extends FunctionSource diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala index f5d51fd328..d727225975 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala @@ -3,6 +3,7 @@ package sttp.tapir.serverless.aws.sam import io.circe.generic.semiauto.deriveEncoder import io.circe.syntax.EncoderOps import io.circe.{Encoder, Json} + import scala.collection.immutable.ListMap object AwsSamTemplateEncoders { @@ -20,10 +21,12 @@ object AwsSamTemplateEncoders { } implicit val encoderHttpProperties: Encoder[HttpProperties] = deriveEncoder[HttpProperties] - implicit val encoderFunctionProperties: Encoder[FunctionProperties] = deriveEncoder[FunctionProperties] + implicit val encoderFunctionImageProperties: Encoder[FunctionImageProperties] = deriveEncoder[FunctionImageProperties] + implicit val encoderFunctionCodeProperties: Encoder[FunctionCodeProperties] = deriveEncoder[FunctionCodeProperties] implicit val encoderProperties: Encoder[Properties] = { - case v: HttpProperties => v.asJson - case v: FunctionProperties => v.asJson + case v: HttpProperties => v.asJson + case v: FunctionImageProperties => v.asJson + case v: FunctionCodeProperties => v.asJson } implicit val encoderHttpResource: Encoder[HttpResource] = deriveEncoder[HttpResource] diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala index ae7742add7..4e42dd96f9 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala @@ -12,17 +12,36 @@ case class SamTemplate( trait Resource { def Properties: Properties } -case class FunctionResource(Properties: FunctionProperties) extends Resource +case class FunctionResource(Properties: Properties) extends Resource case class HttpResource(Properties: HttpProperties) extends Resource trait Properties -case class FunctionProperties( - ImageUri: String, + +trait FunctionProperties { + val Timeout: Long + val MemorySize: Int + val Events: ListMap[String, FunctionHttpApiEvent] +} + +case class FunctionImageProperties( Timeout: Long, MemorySize: Int, Events: ListMap[String, FunctionHttpApiEvent], + ImageUri: String, PackageType: String = "Image" ) extends Properties + with FunctionProperties + +case class FunctionCodeProperties( + Timeout: Long, + MemorySize: Int, + Events: ListMap[String, FunctionHttpApiEvent], + Runtime: String, + CodeUri: String, + Handler: String +) extends Properties + with FunctionProperties + case class HttpProperties(StageName: String) extends Properties case class FunctionHttpApiEvent(Properties: FunctionHttpApiEventProperties) diff --git a/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala b/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala index 997feac74a..8af4901cd8 100644 --- a/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala +++ b/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala @@ -8,7 +8,7 @@ trait TestSuite extends AnyFunSuite with BeforeAndAfterAll { implicit lazy val cs: ContextShift[IO] = IO.contextShift(scala.concurrent.ExecutionContext.global) - def tests: Resource[IO, List[Test]] + def tests: Resource[IO, List[Test[_]]] def testNameFilter: Option[String] = None // define to run a single test (temporarily for debugging) // we need to register the tests when the class is constructed, as otherwise scalatest skips it From a2c129d893beaa2e3db52042f8be44589ab1a6c0 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 11 May 2021 16:06:24 +0200 Subject: [PATCH 03/35] separate module for tests --- build.sbt | 43 ++++++++---- .../aws/lambda/tests/LambdaHandler.scala} | 8 +-- .../aws/lambda/tests/LambdaSamTemplate.scala | 24 +++++++ .../serverless/aws/lambda/tests/package.scala | 32 +++++++++ .../aws/lambda/tests/AwsLambdaHttpTest.scala | 65 +++++++++++++++++++ .../aws/lambda/AwsServerInterpreter.scala | 7 +- .../tapir/serverless/aws/lambda/model.scala | 6 -- .../aws/lambda/AwsLambdaHttpTest.scala | 18 ----- .../lambda/AwsLambdaHttpTestSamTemplate.scala | 25 ------- .../AwsLambdaHttpTestServerInterpreter.scala | 37 ----------- .../scala/sttp/tapir/tests/TestSuite.scala | 2 +- 11 files changed, 161 insertions(+), 106 deletions(-) rename serverless/aws/{lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala => lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala} (80%) create mode 100644 serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala create mode 100644 serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala create mode 100644 serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala delete mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala delete mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala delete mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala diff --git a/build.sbt b/build.sbt index bbb6c3e6a3..33bbb5dc44 100644 --- a/build.sbt +++ b/build.sbt @@ -1,10 +1,10 @@ -import java.net.URL import com.softwaremill.SbtSoftwareMillBrowserTestJS._ import com.softwaremill.UpdateVersionInDocs -import com.typesafe.sbt.packager.docker.ExecCmd import sbt.Reference.display import sbt.internal.ProjectMatrix +import java.net.URL + val scala2_12 = "2.12.13" val scala2_13 = "2.13.5" @@ -17,6 +17,7 @@ scalaVersion := scala2_13 lazy val clientTestServerPort = settingKey[Int]("Port to run the client interpreter test server on") lazy val startClientTestServer = taskKey[Unit]("Start a http server used by client interpreter tests") +lazy val generateSamTemplate = taskKey[Unit]("Generate sam template for lamdba interpreter tests") concurrentRestrictions in Global += Tags.limit(Tags.Test, 1) @@ -251,7 +252,7 @@ lazy val tests: ProjectMatrix = (projectMatrix in file("tests")) "com.beachape" %%% "enumeratum-circe" % Versions.enumeratum, "com.softwaremill.common" %%% "tagging" % "2.3.0", scalaTest.value, - "com.softwaremill.macwire" %% "macros" % "2.3.7" % "provided", + "com.softwaremill.macwire" %% "macros" % "2.3.7", "org.typelevel" %%% "cats-effect" % Versions.catsEffect ), libraryDependencies ++= loggerDependencies @@ -387,7 +388,7 @@ lazy val circeJson: ProjectMatrix = (projectMatrix in file("json/circe")) "io.circe" %%% "circe-core" % Versions.circe, "io.circe" %%% "circe-parser" % Versions.circe, "io.circe" %%% "circe-generic" % Versions.circe, - scalaTest.value % Test, + scalaTest.value % Test ) ) .jvmPlatform(scalaVersions = allScalaVersions) @@ -531,7 +532,7 @@ lazy val opentelemetryMetrics: ProjectMatrix = (projectMatrix in file("metrics/o "io.opentelemetry" % "opentelemetry-api" % "1.1.0", "io.opentelemetry" % "opentelemetry-sdk" % "1.1.0", "io.opentelemetry" % "opentelemetry-sdk-metrics" % "1.1.0-alpha" % Test, - scalaTest.value % Test, + scalaTest.value % Test ) ) .jvmPlatform(scalaVersions = allScalaVersions) @@ -866,17 +867,33 @@ lazy val zioServer: ProjectMatrix = (projectMatrix in file("server/zio-http4s-se // serverless lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda")) + .settings(commonJvmSettings) + .settings(name := "tapir-aws-lambda") + .jvmPlatform(scalaVersions = allScalaVersions) + .dependsOn(core, cats, circeJson, awsSam) + +lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda-tests")) .settings(commonJvmSettings) .settings( - name := "tapir-aws-lambda", - libraryDependencies ++= Seq( - "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "1.0.0" - ), - assembly / assemblyJarName := "tapir-aws-lambda.jar", - Project.inConfig(Test)(baseAssemblySettings) + name := "tapir-aws-lambda-tests", + libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "1.0.0", + assembly / assemblyJarName := "tapir-aws-lambda-tests.jar", + assembly / test := {}, // no tests before building jar + + assembly / assemblyMergeStrategy := { + case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first + case x => (assembly / assemblyMergeStrategy).value(x) + }, + +// test := { +// generateSamTemplate.value +// assembly.value +// }, + + generateSamTemplate := (Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate").value ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core, cats, circeJson, awsSam, serverTests % Test) + .dependsOn(core, cats, circeJson, awsLambda, awsSam, tests, serverTests) lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .settings(commonJvmSettings) @@ -884,7 +901,7 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) name := "tapir-aws-sam", libraryDependencies ++= Seq( "io.circe" %% "circe-yaml" % Versions.circeYaml, - "io.circe" %% "circe-generic" % Versions.circe, + "io.circe" %% "circe-generic" % Versions.circe ) ) .jvmPlatform(scalaVersions = allScalaVersions) diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala similarity index 80% rename from serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala rename to serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 09561e2375..62f16cde28 100644 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -1,4 +1,4 @@ -package sttp.tapir.serverless.aws.lambda +package sttp.tapir.serverless.aws.lambda.tests import cats.effect.IO import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler} @@ -7,16 +7,16 @@ import io.circe.generic.auto._ import io.circe.parser.decode import io.circe.syntax._ import sttp.model.StatusCode -import sttp.tapir.server.ServerEndpoint +import sttp.tapir.serverless.aws.lambda._ import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} import java.nio.charset.StandardCharsets -private[lambda] class AwsLambdaHttpTestHandler(eps: List[ServerEndpoint[_, _, _, Any, IO]]) extends RequestStreamHandler { +object LambdaHandler extends RequestStreamHandler { override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() - val route: Route[IO] = AwsServerInterpreter.toRoute(eps) + val route: Route[IO] = AwsServerInterpreter.toRoute(allEndpoints.toList) val json = new String(input.readAllBytes(), StandardCharsets.UTF_8) (decode[AwsRequest](json) match { diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala new file mode 100644 index 0000000000..a11811e589 --- /dev/null +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala @@ -0,0 +1,24 @@ +package sttp.tapir.serverless.aws.lambda.tests + +import io.circe.syntax._ +import sttp.tapir.serverless.aws.sam.AwsSamTemplateEncoders._ +import sttp.tapir.serverless.aws.sam._ + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} + +object LambdaSamTemplate extends App { + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "Tests", + source = CodeSource( + "java11", + "target/jvm-2.13/tapir-aws-lambda-tests.jar", + "sttp.tapir.serverless.aws.lambda.tests.LambdaHandler::handleRequest" + ), + memorySize = 1024 + ) + val samTemplate = new AwsSamInterpreter().apply(allEndpoints.map(_.endpoint).toList) + val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain).pretty(samTemplate.asJson) + val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/lambda-tests/template.yaml" + Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) +} diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala new file mode 100644 index 0000000000..594c55de5d --- /dev/null +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala @@ -0,0 +1,32 @@ +package sttp.tapir.serverless.aws.lambda + +import cats.effect.IO +import cats.implicits._ +import com.softwaremill.macwire.wireSet +import sttp.model.Header +import sttp.tapir._ +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.tests._ + +package object tests { +// val empty_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.serverLogic(_ => IO.pure(().asRight[Unit])) +// +// val empty_get_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.get.serverLogic(_ => IO.pure(().asRight[Unit])) +// +// val in_query_out_string_endpoint: ServerEndpoint[String, Unit, String, Any, IO] = +// in_query_out_string.serverLogic(fruit => IO.pure(s"fruit: $fruit".asRight[Unit])) +// +// val in_path_path_out_string_endpoint: ServerEndpoint[(String, Port), Unit, String, Any, IO] = in_path_path_out_string.serverLogic { +// case (fruit: String, amount: Int) => IO.pure(s"$fruit $amount".asRight[Unit]) +// } + + val in_string_out_string_endpoint: ServerEndpoint[String, Unit, String, Any, IO] = + in_string_out_string.in("string").serverLogic(s => IO.pure(s.asRight[Unit])) + + val in_json_out_json_endpoint: ServerEndpoint[FruitAmount, Unit, FruitAmount, Any, IO] = + in_json_out_json.in("json").serverLogic(fa => IO.pure(fa.asRight[Unit])) + + val in_headers_out_headers_endpoint: ServerEndpoint[List[Header], Unit, List[Header], Any, IO] = in_headers_out_headers.serverLogic { headers =>IO.pure(headers.asRight[Unit]) } + + val allEndpoints: Set[ServerEndpoint[_, _, _, Any, IO]] = wireSet[ServerEndpoint[_, _, _, Any, IO]] +} diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala new file mode 100644 index 0000000000..ad983badde --- /dev/null +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala @@ -0,0 +1,65 @@ +package sttp.tapir.serverless.aws.lambda.tests + +import cats.effect.IO +import org.scalatest.Assertion +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers._ +import sttp.capabilities.WebSockets +import sttp.capabilities.fs2.Fs2Streams +import sttp.client3.{basicRequest, _} +import sttp.model.{Header, Uri} +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.tests.backendResource + +class AwsLambdaHttpTest extends AnyFunSuite { + + private val baseUri: Uri = uri"http://localhost:3000" + +// testServer(empty_endpoint, "GET empty endpoint") { backend => +// basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) +// } +// +// testServer(empty_endpoint, "POST empty endpoint") { backend => +// basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) +// } +// +// testServer(empty_get_endpoint, "GET a GET endpoint") { backend => +// basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) +// } +// +// testServer(empty_get_endpoint, "POST a GET endpoint") { backend => +// basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) +// } +// +// testServer(in_path_path_out_string_endpoint) { backend => +// basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map(_.body shouldBe Right("orange 20")) +// } +// +// testServer(in_path_path_out_string_endpoint, "with URL encoding") { backend => +// basicRequest.get(uri"$baseUri/fruit/apple%2Fred/amount/20").send(backend).map(_.body shouldBe Right("apple/red 20")) +// } + + testServer(in_string_out_string_endpoint) { backend => + basicRequest.post(uri"$baseUri/api/echo/string").body("Sweet").send(backend).map(_.body shouldBe Right("Sweet")) + } + + testServer(in_json_out_json_endpoint) { backend => + basicRequest + .post(uri"$baseUri/api/echo/json") + .body("""{"fruit":"orange","amount":11}""") + .send(backend) + .map(_.body shouldBe Right("""{"fruit":"orange","amount":11}""")) + } + + testServer(in_headers_out_headers_endpoint) { backend => + basicRequest + .get(uri"$baseUri/api/echo/headers") + .headers(Header.unsafeApply("X-Fruit", "apple"), Header.unsafeApply("Y-Fruit", "Orange")) + .send(backend) + .map(_.headers should contain allOf (Header.unsafeApply("X-Fruit", "apple"), Header.unsafeApply("Y-Fruit", "Orange"))) + } + + private def testServer(t: ServerEndpoint[_, _, _, Any, IO], suffix: String = "")( + f: SttpBackend[IO, Fs2Streams[IO] with WebSockets] => IO[Assertion] + ): Unit = test(s"${t.endpoint.showDetail} $suffix")(backendResource.use(f(_)).unsafeRunSync()) +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index a467e0f5fd..0861ca1e1a 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -40,11 +40,14 @@ trait AwsServerInterpreter { ) interpreter.apply(serverRequest, ses).map { - case None => AwsResponse(Nil, isBase64Encoded = false, StatusCode.NotFound.code, Map.empty, "") + case None => AwsResponse(Nil, isBase64Encoded = true, StatusCode.NotFound.code, Map.empty, "") case Some(res) => + println(res) val cookies = res.cookies.collect { case Right(cookie) => cookie.value }.toList val headers = res.headers.map(h => h.name -> h.value).toMap - AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) + val awsRes = AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) + println(awsRes) + awsRes } } } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala index c81c150bd3..c0403f598e 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/model.scala @@ -1,9 +1,5 @@ package sttp.tapir.serverless.aws.lambda -import com.amazonaws.services.lambda.runtime.Context - -import java.io.{InputStream, OutputStream} - case class AwsRequest( rawPath: String, rawQueryString: String, @@ -16,5 +12,3 @@ case class AwsRequestContext(domainName: Option[String], http: AwsHttp) case class AwsHttp(method: String, path: String, protocol: String, sourceIp: String, userAgent: String) case class AwsResponse(cookies: List[String], isBase64Encoded: Boolean, statusCode: Int, headers: Map[String, String], body: String) - -case class LambdaRuntimeContext(input: InputStream, output: OutputStream, context: Context) diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala deleted file mode 100644 index 667093f2e0..0000000000 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTest.scala +++ /dev/null @@ -1,18 +0,0 @@ -package sttp.tapir.serverless.aws.lambda - -import cats.effect.{IO, Resource} -import sttp.tapir.integ.cats.CatsMonadError -import sttp.tapir.server.tests.{CreateServerTest, ServerBasicTests, backendResource} -import sttp.tapir.tests.{Test, TestSuite} - -class AwsLambdaHttpTest extends TestSuite { - - override def tests: Resource[IO, List[Test]] = backendResource.map { backend => - implicit val m: CatsMonadError[IO] = new CatsMonadError[IO] - val interpreter = new AwsLambdaHttpTestServerInterpreter - val createServerTest = new CreateServerTest(interpreter) - val tests = new ServerBasicTests(backend, createServerTest, interpreter).tests() - - val handler = - } -} diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala deleted file mode 100644 index c09cb3d967..0000000000 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestSamTemplate.scala +++ /dev/null @@ -1,25 +0,0 @@ -package sttp.tapir.serverless.aws.lambda - -import io.circe.syntax._ -import sttp.tapir.Endpoint -import sttp.tapir.serverless.aws.sam._ -import sttp.tapir.tests._ - -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Paths} - -object AwsLambdaHttpTestSamTemplate extends App { - val eps: Set[Endpoint[_, _, _, _]] = allTestEndpoints - implicit val samOptions: AwsSamOptions = AwsSamOptions( - "hello", - source = CodeSource("java11", "target/jvm-2.13/examples.jar", "sttp.tapir.serverless.aws.examples.HelloHandler::handleRequest") - ) - val samTemplate = new AwsSamInterpreter().apply(eps.toList) - - val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain) - .pretty(samTemplate.asJson(AwsSamTemplateEncoders.encoderSamTemplate)) - - val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/examples/template.yaml" - - Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) -} diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala deleted file mode 100644 index 5a10b42500..0000000000 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaHttpTestServerInterpreter.scala +++ /dev/null @@ -1,37 +0,0 @@ -package sttp.tapir.serverless.aws.lambda - -import cats.data.NonEmptyList -import cats.effect.{IO, Resource} -import sttp.tapir.Endpoint -import sttp.tapir.server.ServerEndpoint -import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} -import sttp.tapir.server.interceptor.exception.DefaultExceptionHandler -import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor -import sttp.tapir.server.tests.TestServerInterpreter -import sttp.tapir.tests.Port - -import scala.reflect.ClassTag - -class AwsLambdaHttpTestServerInterpreter extends TestServerInterpreter[IO, Any, Route[IO], String] { - override def route[I, E, O]( - e: ServerEndpoint[I, E, O, Any, IO], - decodeFailureHandler: Option[DecodeFailureHandler], - metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] - ): Route[IO] = { - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]( - metricsInterceptor = metricsInterceptor, - exceptionHandler = Some(DefaultExceptionHandler), - decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) - ) - AwsServerInterpreter.toRoute(e) - } - - override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit - eClassTag: ClassTag[E] - ): Route[IO] = { - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() - AwsServerInterpreter.toRouteRecoverErrors(e)(fn) - } - - override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = Resource.eval(IO.pure(3000)) -} diff --git a/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala b/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala index 8af4901cd8..997feac74a 100644 --- a/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala +++ b/tests/src/main/scala/sttp/tapir/tests/TestSuite.scala @@ -8,7 +8,7 @@ trait TestSuite extends AnyFunSuite with BeforeAndAfterAll { implicit lazy val cs: ContextShift[IO] = IO.contextShift(scala.concurrent.ExecutionContext.global) - def tests: Resource[IO, List[Test[_]]] + def tests: Resource[IO, List[Test]] def testNameFilter: Option[String] = None // define to run a single test (temporarily for debugging) // we need to register the tests when the class is constructed, as otherwise scalatest skips it From e4444664b62d2c16ee9d0ea0a7c5149d56cfb13a Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Wed, 12 May 2021 09:50:13 +0200 Subject: [PATCH 04/35] some it tests --- .../serverless/aws/lambda/tests/package.scala | 21 +++--- .../aws/lambda/tests/AwsLambdaHttpTest.scala | 67 ++++++++++++------- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala index 594c55de5d..2226a296eb 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala @@ -9,16 +9,13 @@ import sttp.tapir.server.ServerEndpoint import sttp.tapir.tests._ package object tests { -// val empty_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.serverLogic(_ => IO.pure(().asRight[Unit])) -// -// val empty_get_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.get.serverLogic(_ => IO.pure(().asRight[Unit])) -// -// val in_query_out_string_endpoint: ServerEndpoint[String, Unit, String, Any, IO] = -// in_query_out_string.serverLogic(fruit => IO.pure(s"fruit: $fruit".asRight[Unit])) -// -// val in_path_path_out_string_endpoint: ServerEndpoint[(String, Port), Unit, String, Any, IO] = in_path_path_out_string.serverLogic { -// case (fruit: String, amount: Int) => IO.pure(s"$fruit $amount".asRight[Unit]) -// } + val empty_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.serverLogic(_ => IO.pure(().asRight[Unit])) + + val empty_get_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.get.serverLogic(_ => IO.pure(().asRight[Unit])) + + val in_path_path_out_string_endpoint: ServerEndpoint[(String, Port), Unit, String, Any, IO] = in_path_path_out_string.serverLogic { + case (fruit: String, amount: Int) => IO.pure(s"$fruit $amount".asRight[Unit]) + } val in_string_out_string_endpoint: ServerEndpoint[String, Unit, String, Any, IO] = in_string_out_string.in("string").serverLogic(s => IO.pure(s.asRight[Unit])) @@ -26,7 +23,9 @@ package object tests { val in_json_out_json_endpoint: ServerEndpoint[FruitAmount, Unit, FruitAmount, Any, IO] = in_json_out_json.in("json").serverLogic(fa => IO.pure(fa.asRight[Unit])) - val in_headers_out_headers_endpoint: ServerEndpoint[List[Header], Unit, List[Header], Any, IO] = in_headers_out_headers.serverLogic { headers =>IO.pure(headers.asRight[Unit]) } + val in_headers_out_headers_endpoint: ServerEndpoint[List[Header], Unit, List[Header], Any, IO] = in_headers_out_headers.serverLogic { + headers => IO.pure(headers.asRight[Unit]) + } val allEndpoints: Set[ServerEndpoint[_, _, _, Any, IO]] = wireSet[ServerEndpoint[_, _, _, Any, IO]] } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala index ad983badde..0194fc3c4d 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala @@ -1,7 +1,8 @@ package sttp.tapir.serverless.aws.lambda.tests import cats.effect.IO -import org.scalatest.Assertion +import org.scalatest.Assertions +import org.scalatest.compatible.Assertion import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers._ import sttp.capabilities.WebSockets @@ -11,36 +12,44 @@ import sttp.model.{Header, Uri} import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.tests.backendResource +import java.util.Base64 + class AwsLambdaHttpTest extends AnyFunSuite { private val baseUri: Uri = uri"http://localhost:3000" -// testServer(empty_endpoint, "GET empty endpoint") { backend => -// basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) -// } -// -// testServer(empty_endpoint, "POST empty endpoint") { backend => -// basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) -// } -// -// testServer(empty_get_endpoint, "GET a GET endpoint") { backend => -// basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) -// } -// -// testServer(empty_get_endpoint, "POST a GET endpoint") { backend => -// basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) -// } -// -// testServer(in_path_path_out_string_endpoint) { backend => -// basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map(_.body shouldBe Right("orange 20")) -// } -// -// testServer(in_path_path_out_string_endpoint, "with URL encoding") { backend => -// basicRequest.get(uri"$baseUri/fruit/apple%2Fred/amount/20").send(backend).map(_.body shouldBe Right("apple/red 20")) -// } + testServer(empty_endpoint, "GET empty endpoint") { backend => + basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) + } + + testServer(empty_endpoint, "POST empty endpoint") { backend => + basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) + } + + testServer(empty_get_endpoint, "GET a GET endpoint") { backend => + basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) + } + + testServer(empty_get_endpoint, "POST a GET endpoint") { backend => + basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) + } + + testServer(in_path_path_out_string_endpoint) { backend => + basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map { req => + req.body.map(b => decode(b) shouldBe "orange 20").getOrElse(Assertions.fail()) + } + } + + testServer(in_path_path_out_string_endpoint, "with URL encoding") { backend => + basicRequest.get(uri"$baseUri/fruit/apple%2Fred/amount/20").send(backend).map { req => + req.body.map(b => decode(b) shouldBe "apple/red 20").getOrElse(Assertions.fail()) + } + } testServer(in_string_out_string_endpoint) { backend => - basicRequest.post(uri"$baseUri/api/echo/string").body("Sweet").send(backend).map(_.body shouldBe Right("Sweet")) + basicRequest.post(uri"$baseUri/api/echo/string").body("Sweet").send(backend).map { req => + req.body.map(b => decode(b) shouldBe "Sweet").getOrElse(Assertions.fail()) + } } testServer(in_json_out_json_endpoint) { backend => @@ -48,7 +57,11 @@ class AwsLambdaHttpTest extends AnyFunSuite { .post(uri"$baseUri/api/echo/json") .body("""{"fruit":"orange","amount":11}""") .send(backend) - .map(_.body shouldBe Right("""{"fruit":"orange","amount":11}""")) + .map { req => + req.body + .map(b => decode(b) shouldBe """{"fruit":"orange","amount":11}""") + .getOrElse(Assertions.fail()) + } } testServer(in_headers_out_headers_endpoint) { backend => @@ -59,6 +72,8 @@ class AwsLambdaHttpTest extends AnyFunSuite { .map(_.headers should contain allOf (Header.unsafeApply("X-Fruit", "apple"), Header.unsafeApply("Y-Fruit", "Orange"))) } + private def decode(enc: String): String = new String(Base64.getDecoder.decode(enc)) + private def testServer(t: ServerEndpoint[_, _, _, Any, IO], suffix: String = "")( f: SttpBackend[IO, Fs2Streams[IO] with WebSockets] => IO[Assertion] ): Unit = test(s"${t.endpoint.showDetail} $suffix")(backendResource.use(f(_)).unsafeRunSync()) From bba11db2b6813a8ced62a072e41e001dfb6adb4b Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Wed, 12 May 2021 15:39:15 +0200 Subject: [PATCH 05/35] basic tests with stub backend --- build.sbt | 14 +- .../server/akkahttp/AkkaHttpServerTest.scala | 14 +- .../finatra/cats/FinatraServerCatsTests.scala | 4 +- .../server/finatra/FinatraServerTest.scala | 10 +- .../server/http4s/Http4sServerTest.scala | 35 +-- .../tapir/server/play/PlayServerTest.scala | 12 +- .../tapir/server/stub/SttpStubServer.scala | 6 +- ...erverTest.scala => CreateTestServer.scala} | 43 ++- .../tests/ServerAuthenticationTests.scala | 24 +- .../tapir/server/tests/ServerBasicTests.scala | 264 +++++++++--------- .../server/tests/ServerMetricsTest.scala | 18 +- .../server/tests/ServerStreamingTests.scala | 29 +- .../server/tests/ServerWebSocketTests.scala | 17 +- .../server/vertx/CatsVertxServerTest.scala | 4 +- .../vertx/VertxBlockingServerTest.scala | 4 +- .../tapir/server/vertx/VertxServerTest.scala | 12 +- .../server/vertx/ZioVertxServerTest.scala | 4 +- .../aws/lambda/tests/LambdaHandler.scala | 3 + .../serverless/aws/lambda/tests/package.scala | 3 - .../aws/lambda/tests/AwsLambdaHttpTest.scala | 20 +- .../lambda/tests/AwsLambdaStubHttpTest.scala | 17 ++ .../AwsLambdaTestServerInterpreter.scala | 35 +++ .../lambda/tests/LambdaStubTestServer.scala | 121 ++++++++ .../aws/lambda/AwsServerRequest.scala | 7 +- 24 files changed, 457 insertions(+), 263 deletions(-) rename server/tests/src/main/scala/sttp/tapir/server/tests/{CreateServerTest.scala => CreateTestServer.scala} (51%) create mode 100644 serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala create mode 100644 serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala create mode 100644 serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala diff --git a/build.sbt b/build.sbt index 33bbb5dc44..ff23a6a950 100644 --- a/build.sbt +++ b/build.sbt @@ -879,21 +879,19 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "1.0.0", assembly / assemblyJarName := "tapir-aws-lambda-tests.jar", assembly / test := {}, // no tests before building jar - - assembly / assemblyMergeStrategy := { - case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first - case x => (assembly / assemblyMergeStrategy).value(x) - }, - + assembly / assemblyMergeStrategy := { + case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first + case x => (assembly / assemblyMergeStrategy).value(x) + }, // test := { // generateSamTemplate.value // assembly.value // }, - + Test / parallelExecution := false, generateSamTemplate := (Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate").value ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core, cats, circeJson, awsLambda, awsSam, tests, serverTests) + .dependsOn(core, cats, circeJson, awsLambda, awsSam, sttpStubServer, tests, serverTests) lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .settings(commonJvmSettings) diff --git a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala index 6df022d607..84730e353c 100644 --- a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala +++ b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala @@ -16,7 +16,7 @@ import sttp.monad.FutureMonad import sttp.monad.syntax._ import sttp.tapir._ import sttp.tapir.server.tests.{ - CreateServerTest, + CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, @@ -43,7 +43,7 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { implicit val m: FutureMonad = new FutureMonad()(actorSystem.dispatcher) val interpreter = new AkkaHttpTestServerInterpreter()(actorSystem) - val createServerTest = new CreateServerTest(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) def additionalTests(): List[Test] = List( Test("endpoint nested in a path directive") { @@ -86,13 +86,13 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { } ) - new ServerBasicTests(backend, createServerTest, interpreter).tests() ++ - new ServerStreamingTests(backend, createServerTest, AkkaStreams).tests() ++ - new ServerWebSocketTests(backend, createServerTest, AkkaStreams) { + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerStreamingTests(createTestServer, AkkaStreams).tests() ++ + new ServerWebSocketTests(createTestServer, AkkaStreams) { override def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] = Flow.fromFunction(f) }.tests() ++ - new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerMetricsTest(backend, createServerTest).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerMetricsTest(createTestServer).tests() ++ additionalTests() } } diff --git a/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala b/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala index fbe8037474..7736cf43f7 100644 --- a/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala +++ b/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.finatra.cats import cats.effect.{IO, Resource} import sttp.client3.impl.cats.CatsMonadAsyncError -import sttp.tapir.server.tests.{CreateServerTest, ServerAuthenticationTests, ServerBasicTests, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} class FinatraServerCatsTests extends TestSuite { @@ -10,7 +10,7 @@ class FinatraServerCatsTests extends TestSuite { implicit val m: CatsMonadAsyncError[IO] = new CatsMonadAsyncError[IO]() val interpreter = new FinatraCatsTestServerInterpreter() - val createServerTest = new CreateServerTest(interpreter) + val createServerTest = new CreateTestServer(interpreter) new ServerBasicTests(backend, createServerTest, interpreter).tests() ++ new ServerAuthenticationTests(backend, createServerTest).tests() diff --git a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala index 0812b2a5d6..2ae601f1fe 100644 --- a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala +++ b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala @@ -3,19 +3,19 @@ package sttp.tapir.server.finatra import cats.effect.{IO, Resource} import sttp.monad.MonadError import sttp.tapir.server.finatra.FinatraServerInterpreter.FutureMonadError -import sttp.tapir.server.tests.{CreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} import sttp.tapir.tests.{Test, TestSuite} class FinatraServerTest extends TestSuite { override def tests: Resource[IO, List[Test]] = backendResource.map { backend => val interpreter = new FinatraTestServerInterpreter() - val createServerTest = new CreateServerTest(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) implicit val m: MonadError[com.twitter.util.Future] = FutureMonadError - new ServerBasicTests(backend, createServerTest, interpreter).tests() ++ - new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerMetricsTest(backend, createServerTest).tests() + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerMetricsTest(createTestServer).tests() } } diff --git a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala index 846173d52d..1af6ecd11e 100644 --- a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala +++ b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala @@ -2,6 +2,7 @@ package sttp.tapir.server.http4s import cats.effect._ import cats.syntax.all._ +import org.http4s.HttpRoutes import org.http4s.server.Router import org.http4s.server.blaze.BlazeServerBuilder import org.http4s.syntax.kleisli._ @@ -13,15 +14,7 @@ import sttp.client3._ import sttp.model.sse.ServerSentEvent import sttp.tapir._ import sttp.tapir.integ.cats.CatsMonadError -import sttp.tapir.server.tests.{ - CreateServerTest, - ServerAuthenticationTests, - ServerBasicTests, - ServerMetricsTest, - ServerStreamingTests, - ServerWebSocketTests, - backendResource -} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import sttp.ws.{WebSocket, WebSocketFrame} @@ -36,7 +29,7 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi implicit val m: CatsMonadError[IO] = new CatsMonadError[IO] val interpreter = new Http4sTestServerInterpreter() - val createServerTest = new CreateServerTest(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) def randomUUID = Some(UUID.randomUUID().toString) val sse1 = ServerSentEvent(randomUUID, randomUUID, randomUUID, Some(Random.nextInt(200))) val sse2 = ServerSentEvent(randomUUID, randomUUID, randomUUID, Some(Random.nextInt(200))) @@ -58,14 +51,14 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi } .unsafeRunSync() }, - createServerTest.testServer( + createTestServer.testServer( endpoint.out( webSocketBody[String, CodecFormat.TextPlain, String, CodecFormat.TextPlain] .apply(Fs2Streams[IO]) .autoPing(Some((1.second, WebSocketFrame.ping))) ), "automatic pings" - )((_: Unit) => IO(Right((in: fs2.Stream[IO, String]) => in))) { baseUri => + )((_: Unit) => IO(Right((in: fs2.Stream[IO, String]) => in))) { (backend, baseUri) => basicRequest .response(asWebSocket { ws: WebSocket[IO] => List(ws.receive().timeout(60.seconds), ws.receive().timeout(60.seconds)).sequence @@ -74,12 +67,12 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi .send(backend) .map(_.body should matchPattern { case Right(List(WebSocketFrame.Ping(_), WebSocketFrame.Ping(_))) => }) }, - createServerTest.testServer( + createTestServer.testServer( endpoint.out(streamBinaryBody(Fs2Streams[IO])), "streaming should send data according to producer stream rate" )((_: Unit) => IO(Right(fs2.Stream.awakeEvery[IO](1.second).map(_.toString()).through(fs2.text.utf8Encode).interruptAfter(5.seconds))) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .response( asStream(Fs2Streams[IO])(bs => { @@ -93,10 +86,10 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi case wrongResponse => fail(s"expected to get count of received data chunks, instead got $wrongResponse") }) }, - createServerTest.testServer( + createTestServer.testServer( endpoint.out(serverSentEventsBody[IO]), "Send and receive SSE" - )((_: Unit) => IO(Right(fs2.Stream(sse1, sse2)))) { baseUri => + )((_: Unit) => IO(Right(fs2.Stream(sse1, sse2)))) { (backend, baseUri) => basicRequest .response(asStream[IO, List[ServerSentEvent], Fs2Streams[IO]](Fs2Streams[IO]) { stream => Http4sServerSentEvents @@ -111,12 +104,12 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi } ) - new ServerBasicTests(backend, createServerTest, interpreter).tests() ++ - new ServerStreamingTests(backend, createServerTest, Fs2Streams[IO]).tests() ++ - new ServerWebSocketTests(backend, createServerTest, Fs2Streams[IO]) { + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerStreamingTests(createTestServer, Fs2Streams[IO]).tests() ++ + new ServerWebSocketTests(createTestServer, Fs2Streams[IO]) { override def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] = in => in.map(f) }.tests() ++ - new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerMetricsTest(backend, createServerTest).tests() ++ additionalTests() + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerMetricsTest(createTestServer).tests() ++ additionalTests() } } diff --git a/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala b/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala index 981aa3ece9..eeed4882e8 100644 --- a/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala +++ b/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.play import akka.actor.ActorSystem import cats.effect.{IO, Resource} import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{CreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} import sttp.tapir.tests.{Test, TestSuite} class PlayServerTest extends TestSuite { @@ -16,17 +16,17 @@ class PlayServerTest extends TestSuite { implicit val m: FutureMonad = new FutureMonad()(actorSystem.dispatcher) val interpreter = new PlayTestServerInterpreter()(actorSystem) - val createServerTest = new CreateServerTest(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) new ServerBasicTests( - backend, - createServerTest, + createTestServer, interpreter, multipleValueHeaderSupport = false, multipartInlineHeaderSupport = false, inputStreamSupport = false - ).tests() ++ new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerMetricsTest(backend, createServerTest).tests() + ).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerMetricsTest(createTestServer).tests() } } } diff --git a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala index 48573c9c07..034fd8e6d3 100644 --- a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala +++ b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala @@ -17,7 +17,6 @@ import java.nio.ByteBuffer import java.nio.charset.Charset import scala.collection.immutable.Seq import scala.util.{Success, Try} -import sttp.monad.syntax._ trait SttpStubServer { @@ -39,6 +38,11 @@ trait SttpStubServer { ): SttpBackendStub[F, R] = _whenInputMatches(endpoint.endpoint)(inputMatcher).thenRespondF(req => interpretRequest(req, endpoint, interceptors)) + def whenRequestMatchesEndpointThenInterpret[I, E, O]( + endpoint: Endpoint[I, E, O, R], + interpret: Request[_, _] => F[Response[_]] + ): SttpBackendStub[F, R] = _whenRequestMatches(endpoint).thenRespondF(interpret(_)) + private def _whenRequestMatches[E, O](endpoint: Endpoint[_, E, O, _]): stub.WhenRequest = { new stub.WhenRequest(req => DecodeBasicInputs(endpoint.input, new SttpRequest(req)) match { diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/CreateServerTest.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/CreateTestServer.scala similarity index 51% rename from server/tests/src/main/scala/sttp/tapir/server/tests/CreateServerTest.scala rename to server/tests/src/main/scala/sttp/tapir/server/tests/CreateTestServer.scala index 5e503298de..d6d8340159 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/CreateServerTest.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/CreateTestServer.scala @@ -5,37 +5,47 @@ import cats.effect.{IO, Resource} import cats.implicits._ import com.typesafe.scalalogging.StrictLogging import org.scalatest.Assertion +import sttp.capabilities.WebSockets +import sttp.capabilities.fs2.Fs2Streams import sttp.client3._ import sttp.model._ import sttp.tapir._ import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.decodefailure.DecodeFailureHandler -import sttp.tapir.server.interceptor.metrics.{MetricsEndpointInterceptor, MetricsRequestInterceptor} +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import sttp.tapir.tests._ -class CreateServerTest[F[_], +R, ROUTE, B](interpreter: TestServerInterpreter[F, R, ROUTE, B]) extends StrictLogging { - def testServer[I, E, O]( +class CreateTestServer[F[_], +R, ROUTE, B]( + backend: SttpBackend[IO, R], + interpreter: TestServerInterpreter[F, R, ROUTE, B] +) extends TestServer[F, R, ROUTE, B] + with StrictLogging { + override def testServer[I, E, O]( e: Endpoint[I, E, O, R], testNameSuffix: String = "", decodeFailureHandler: Option[DecodeFailureHandler] = None, metricsInterceptor: Option[MetricsRequestInterceptor[F, B]] = None )( fn: I => F[Either[E, O]] - )(runTest: Uri => IO[Assertion]): Test = { + )(runTest: (SttpBackend[IO, R], Uri) => IO[Assertion]): Test = { testServer( e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix), NonEmptyList.of(interpreter.route(e.serverLogic(fn), decodeFailureHandler, metricsInterceptor)) )(runTest) } - def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, R, F], testNameSuffix: String = "")(runTest: Uri => IO[Assertion]): Test = { + override def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, R, F], testNameSuffix: String = "")( + runTest: (SttpBackend[IO, R], Uri) => IO[Assertion] + ): Test = { testServer( e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix), NonEmptyList.of(interpreter.route(e)) )(runTest) } - def testServer(name: String, rs: => NonEmptyList[ROUTE])(runTest: Uri => IO[Assertion]): Test = { + override def testServer(name: String, rs: => NonEmptyList[ROUTE])( + runTest: (SttpBackend[IO, R], Uri) => IO[Assertion] + ): Test = { val resources = for { port <- interpreter.server(rs).onError { case e: Exception => Resource.eval(IO(logger.error(s"Starting server failed because of ${e.getMessage}"))) @@ -46,9 +56,28 @@ class CreateServerTest[F[_], +R, ROUTE, B](interpreter: TestServerInterpreter[F, Test(name)( resources .use { port => - runTest(uri"http://localhost:$port").guarantee(IO(logger.info(s"Tests completed on port $port"))) + runTest(backend, uri"http://localhost:$port").guarantee(IO(logger.info(s"Tests completed on port $port"))) } .unsafeRunSync() ) } } + +object CreateTestServer { + type StreamsWithWebsockets = Fs2Streams[IO] with WebSockets +} + +trait TestServer[F[_], +R, ROUTE, B] { + def testServer[I, E, O]( + e: Endpoint[I, E, O, R], + testNameSuffix: String = "", + decodeFailureHandler: Option[DecodeFailureHandler] = None, + metricsInterceptor: Option[MetricsRequestInterceptor[F, B]] = None + )(fn: I => F[Either[E, O]])(runTest: (SttpBackend[IO, R], Uri) => IO[Assertion]): Test + + def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, R, F], testNameSuffix: String = "")( + runTest: (SttpBackend[IO, R], Uri) => IO[Assertion] + ): Test + + def testServer(name: String, rs: => NonEmptyList[ROUTE])(runTest: (SttpBackend[IO, R], Uri) => IO[Assertion]): Test +} diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala index 101ea3746d..d3012c1e25 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala @@ -1,22 +1,20 @@ package sttp.tapir.server.tests import cats.effect.IO +import cats.implicits._ import org.scalatest.matchers.should.Matchers import sttp.client3._ -import sttp.model._ -import sttp.model.StatusCode -import sttp.monad.MonadError -import sttp.tapir._ -import sttp.tapir.tests.Test -import cats.implicits._ import sttp.model.Uri.QuerySegment +import sttp.model.{StatusCode, _} +import sttp.monad.MonadError import sttp.tapir.EndpointInput.WWWAuthenticate +import sttp.tapir._ import sttp.tapir.model.UsernamePassword +import sttp.tapir.tests.Test -class ServerAuthenticationTests[F[_], S, ROUTE, B](backend: SttpBackend[IO, Any], serverTests: CreateServerTest[F, S, ROUTE, B])(implicit - m: MonadError[F] -) extends Matchers { - import serverTests._ +class ServerAuthenticationTests[F[_], S, ROUTE, B](createTestServer: TestServer[F, S, ROUTE, B])(implicit m: MonadError[F]) + extends Matchers { + import createTestServer._ private val Realm = "realm" private val base = endpoint.post.in("secret" / path[Long]("id")).in(query[String]("q")) @@ -50,7 +48,7 @@ class ServerAuthenticationTests[F[_], S, ROUTE, B](backend: SttpBackend[IO, Any] def tests(): List[Test] = missingAuthTests ++ correctAuthTests ++ badRequestWithCorrectAuthTests private def missingAuthTests = endpoints.map { case (authType, endpoint, _) => - testServer(endpoint, s"missing $authType")(_ => result) { baseUri => + testServer(endpoint, s"missing $authType")(_ => result) { (backend, baseUri) => validRequest(baseUri).send(backend).map { r => r.code shouldBe StatusCode.Unauthorized r.header("WWW-Authenticate") shouldBe Some(expectedChallenge(authType)) @@ -65,7 +63,7 @@ class ServerAuthenticationTests[F[_], S, ROUTE, B](backend: SttpBackend[IO, Any] } private def correctAuthTests = endpoints.map { case (authType, endpoint, auth) => - testServer(endpoint, s"correct $authType")(_ => result) { baseUri => + testServer(endpoint, s"correct $authType")(_ => result) { (backend, baseUri) => auth(validRequest(baseUri)) .send(backend) .map(_.code shouldBe StatusCode.Ok) @@ -73,7 +71,7 @@ class ServerAuthenticationTests[F[_], S, ROUTE, B](backend: SttpBackend[IO, Any] } private def badRequestWithCorrectAuthTests = endpoints.map { case (authType, endpoint, auth) => - testServer(endpoint, s"invalid request $authType")(_ => result) { baseUri => + testServer(endpoint, s"invalid request $authType")(_ => result) { (backend, baseUri) => auth(invalidRequest(baseUri)).send(backend).map(_.code shouldBe StatusCode.BadRequest) } } diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala index 14cb34dea1..d346bb5731 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala @@ -31,8 +31,7 @@ import scala.concurrent.Await import scala.concurrent.duration.DurationInt class ServerBasicTests[F[_], ROUTE, B]( - backend: SttpBackend[IO, Any], - createServerTest: CreateServerTest[F, Any, ROUTE, B], + createTestServer: TestServer[F, Any, ROUTE, B], serverInterpreter: TestServerInterpreter[F, Any, ROUTE, B], multipleValueHeaderSupport: Boolean = true, multipartInlineHeaderSupport: Boolean = true, @@ -40,7 +39,7 @@ class ServerBasicTests[F[_], ROUTE, B]( )(implicit m: MonadError[F] ) { - import createServerTest._ + import createTestServer._ import serverInterpreter._ private val basicStringRequest = basicRequest.response(asStringAlways) @@ -55,111 +54,115 @@ class ServerBasicTests[F[_], ROUTE, B]( def basicTests(): List[Test] = List( testServer(in_string_out_status_from_type_erasure_using_partial_matcher)((v: String) => pureResult((if (v == "right") Some(Right("right")) else if (v == "left") Some(Left(42)) else None).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=nothing").send(backend).map(_.code shouldBe StatusCode.NoContent) >> basicRequest.get(uri"$baseUri?fruit=right").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri?fruit=left").send(backend).map(_.code shouldBe StatusCode.Accepted) }, // method matching - testServer(endpoint, "GET empty endpoint")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(endpoint, "GET empty endpoint")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) }, - testServer(endpoint, "POST empty endpoint")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(endpoint, "POST empty endpoint")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) }, - testServer(endpoint.get, "GET a GET endpoint")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(endpoint.get, "GET a GET endpoint")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) }, - testServer(endpoint.get, "POST a GET endpoint")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(endpoint.get, "POST a GET endpoint")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.post(baseUri).send(backend).map(_.body shouldBe Symbol("left")) }, // - testServer(in_query_out_string)((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { baseUri => + testServer(in_query_out_string)((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.body shouldBe Right("fruit: orange")) }, - testServer(in_query_out_string, "with URL encoding")((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { baseUri => - basicRequest.get(uri"$baseUri?fruit=red%20apple").send(backend).map(_.body shouldBe Right("fruit: red apple")) + testServer(in_query_out_string, "with URL encoding")((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri?fruit=red%20apple").send(backend).map(_.body shouldBe Right("fruit: red apple")) }, testServer[String, Nothing, String](in_query_out_infallible_string)((fruit: String) => pureResult(s"fruit: $fruit".asRight[Nothing])) { - baseUri => + (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=kiwi").send(backend).map(_.body shouldBe Right("fruit: kiwi")) }, testServer(in_query_query_out_string) { case (fruit: String, amount: Option[Int]) => pureResult(s"$fruit $amount".asRight[Unit]) } { - baseUri => + (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.body shouldBe Right("orange None")) *> basicRequest.get(uri"$baseUri?fruit=orange&amount=10").send(backend).map(_.body shouldBe Right("orange Some(10)")) }, - testServer(in_header_out_string)((p1: String) => pureResult(s"$p1".asRight[Unit])) { baseUri => + testServer(in_header_out_string)((p1: String) => pureResult(s"$p1".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri").header("X-Role", "Admin").send(backend).map(_.body shouldBe Right("Admin")) }, - testServer(in_path_path_out_string) { case (fruit: String, amount: Int) => pureResult(s"$fruit $amount".asRight[Unit]) } { baseUri => - basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map(_.body shouldBe Right("orange 20")) + testServer(in_path_path_out_string) { case (fruit: String, amount: Int) => pureResult(s"$fruit $amount".asRight[Unit]) } { + (backend, baseUri) => + basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map(_.body shouldBe Right("orange 20")) }, testServer(in_path_path_out_string, "with URL encoding") { case (fruit: String, amount: Int) => pureResult(s"$fruit $amount".asRight[Unit]) - } { baseUri => + } { (backend, baseUri) => basicRequest.get(uri"$baseUri/fruit/apple%2Fred/amount/20").send(backend).map(_.body shouldBe Right("apple/red 20")) }, - testServer(in_path, "Empty path should not be passed to path capture decoding") { _ => pureResult(Right(())) } { baseUri => + testServer(in_path, "Empty path should not be passed to path capture decoding") { _ => pureResult(Right(())) } { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/").send(backend).map(_.code shouldBe StatusCode.NotFound) }, testServer(in_two_path_capture, "capturing two path parameters with the same specification") { case (a: Int, b: Int) => pureResult(Right((a, b))) - } { baseUri => + } { (backend, baseUri) => basicRequest.get(uri"$baseUri/in/12/23").send(backend).map { response => response.header("a") shouldBe Some("12") response.header("b") shouldBe Some("23") } }, - testServer(in_string_out_string)((b: String) => pureResult(b.asRight[Unit])) { baseUri => + testServer(in_string_out_string)((b: String) => pureResult(b.asRight[Unit])) { (backend, baseUri) => basicRequest.post(uri"$baseUri/api/echo").body("Sweet").send(backend).map(_.body shouldBe Right("Sweet")) }, - testServer(in_string_out_string, "with get method")((b: String) => pureResult(b.asRight[Unit])) { baseUri => + testServer(in_string_out_string, "with get method")((b: String) => pureResult(b.asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/echo").body("Sweet").send(backend).map(_.body shouldBe Symbol("left")) }, - testServer(in_mapped_query_out_string)((fruit: List[Char]) => pureResult(s"fruit length: ${fruit.length}".asRight[Unit])) { baseUri => - basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.body shouldBe Right("fruit length: 6")) + testServer(in_mapped_query_out_string)((fruit: List[Char]) => pureResult(s"fruit length: ${fruit.length}".asRight[Unit])) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.body shouldBe Right("fruit length: 6")) }, - testServer(in_mapped_path_out_string)((fruit: Fruit) => pureResult(s"$fruit".asRight[Unit])) { baseUri => + testServer(in_mapped_path_out_string)((fruit: Fruit) => pureResult(s"$fruit".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/fruit/kiwi").send(backend).map(_.body shouldBe Right("Fruit(kiwi)")) }, - testServer(in_mapped_path_path_out_string)((p1: FruitAmount) => pureResult(s"FA: $p1".asRight[Unit])) { baseUri => + testServer(in_mapped_path_path_out_string)((p1: FruitAmount) => pureResult(s"FA: $p1".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/fruit/orange/amount/10").send(backend).map(_.body shouldBe Right("FA: FruitAmount(orange,10)")) }, testServer(in_query_mapped_path_path_out_string) { case (fa: FruitAmount, color: String) => pureResult(s"FA: $fa color: $color".asRight[Unit]) - } { baseUri => + } { (backend, baseUri) => basicRequest .get(uri"$baseUri/fruit/orange/amount/10?color=yellow") .send(backend) .map(_.body shouldBe Right("FA: FruitAmount(orange,10) color: yellow")) }, - testServer(in_query_out_mapped_string)((p1: String) => pureResult(p1.toList.asRight[Unit])) { baseUri => + testServer(in_query_out_mapped_string)((p1: String) => pureResult(p1.toList.asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.body shouldBe Right("orange")) }, - testServer(in_query_out_mapped_string_header)((p1: String) => pureResult(FruitAmount(p1, p1.length).asRight[Unit])) { baseUri => - basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map { r => - r.body shouldBe Right("orange") - r.header("X-Role") shouldBe Some("6") - } + testServer(in_query_out_mapped_string_header)((p1: String) => pureResult(FruitAmount(p1, p1.length).asRight[Unit])) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map { r => + r.body shouldBe Right("orange") + r.header("X-Role") shouldBe Some("6") + } }, testServer(in_header_before_path, "Header input before path capture input") { case (str: String, i: Int) => pureResult((i, str).asRight[Unit]) - } { baseUri => + } { (backend, baseUri) => basicRequest.get(uri"$baseUri/12").header("SomeHeader", "hello").send(backend).map { response => response.body shouldBe Right("hello") response.header("IntHeader") shouldBe Some("12") } }, testServer(in_json_out_json)((fa: FruitAmount) => pureResult(FruitAmount(fa.fruit + " banana", fa.amount * 2).asRight[Unit])) { - baseUri => + (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .body("""{"fruit":"orange","amount":11}""") .send(backend) .map(_.body shouldBe Right("""{"fruit":"orange banana","amount":22}""")) }, - testServer(in_json_out_json, "with accept header")((fa: FruitAmount) => pureResult(fa.asRight[Unit])) { baseUri => + testServer(in_json_out_json, "with accept header")((fa: FruitAmount) => pureResult(fa.asRight[Unit])) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .body("""{"fruit":"banana","amount":12}""") @@ -167,29 +170,29 @@ class ServerBasicTests[F[_], ROUTE, B]( .send(backend) .map(_.body shouldBe Right("""{"fruit":"banana","amount":12}""")) }, - testServer(in_json_out_json, "content type")((fa: FruitAmount) => pureResult(fa.asRight[Unit])) { baseUri => + testServer(in_json_out_json, "content type")((fa: FruitAmount) => pureResult(fa.asRight[Unit])) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .body("""{"fruit":"banana","amount":12}""") .send(backend) .map(_.contentType shouldBe Some(sttp.model.MediaType.ApplicationJson.toString)) }, - testServer(in_byte_array_out_byte_array)((b: Array[Byte]) => pureResult(b.asRight[Unit])) { baseUri => + testServer(in_byte_array_out_byte_array)((b: Array[Byte]) => pureResult(b.asRight[Unit])) { (backend, baseUri) => basicRequest.post(uri"$baseUri/api/echo").body("banana kiwi".getBytes).send(backend).map(_.body shouldBe Right("banana kiwi")) }, - testServer(in_byte_buffer_out_byte_buffer)((b: ByteBuffer) => pureResult(b.asRight[Unit])) { baseUri => + testServer(in_byte_buffer_out_byte_buffer)((b: ByteBuffer) => pureResult(b.asRight[Unit])) { (backend, baseUri) => basicRequest.post(uri"$baseUri/api/echo").body("mango").send(backend).map(_.body shouldBe Right("mango")) }, - testServer(in_unit_out_json_unit, "unit json mapper")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(in_unit_out_json_unit, "unit json mapper")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/unit").send(backend).map(_.body shouldBe Right("{}")) }, - testServer(in_unit_out_string, "default status mapper")((_: Unit) => pureResult("".asRight[Unit])) { baseUri => + testServer(in_unit_out_string, "default status mapper")((_: Unit) => pureResult("".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/not-existing-path").send(backend).map(_.code shouldBe StatusCode.NotFound) }, - testServer(in_unit_error_out_string, "default error status mapper")((_: Unit) => pureResult("".asLeft[Unit])) { baseUri => + testServer(in_unit_error_out_string, "default error status mapper")((_: Unit) => pureResult("".asLeft[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api").send(backend).map(_.code shouldBe StatusCode.BadRequest) }, - testServer(in_file_out_file)((file: File) => pureResult(file.asRight[Unit])) { baseUri => + testServer(in_file_out_file)((file: File) => pureResult(file.asRight[Unit])) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .body("pen pineapple apple pen") @@ -197,7 +200,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .map(_.body shouldBe Right("pen pineapple apple pen")) }, testServer(in_form_out_form)((fa: FruitAmount) => pureResult(fa.copy(fruit = fa.fruit.reverse, amount = fa.amount + 1).asRight[Unit])) { - baseUri => + (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .body(Map("fruit" -> "plum", "amount" -> "10")) @@ -206,7 +209,7 @@ class ServerBasicTests[F[_], ROUTE, B]( }, testServer(in_query_params_out_string)((mqp: QueryParams) => pureResult(mqp.toSeq.sortBy(_._1).map(p => s"${p._1}=${p._2}").mkString("&").asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => val params = Map("name" -> "apple", "weight" -> "42", "kind" -> "very good") basicRequest .get(uri"$baseUri/api/echo/params?$params") @@ -215,29 +218,29 @@ class ServerBasicTests[F[_], ROUTE, B]( }, testServer(in_query_params_out_string, "should support value-less query param")((mqp: QueryParams) => pureResult(mqp.toMultiMap.map(data => s"${data._1}=${data._2}").mkString("&").asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .get(uri"$baseUri/api/echo/params?flag") .send(backend) .map(_.body shouldBe Right("flag=List()")) }, testServer(in_headers_out_headers)((hs: List[Header]) => pureResult(hs.map(h => Header(h.name, h.value.reverse)).asRight[Unit])) { - baseUri => + (backend, baseUri) => basicRequest .get(uri"$baseUri/api/echo/headers") .headers(Header.unsafeApply("X-Fruit", "apple"), Header.unsafeApply("Y-Fruit", "Orange")) .send(backend) .map(_.headers should contain allOf (Header.unsafeApply("X-Fruit", "elppa"), Header.unsafeApply("Y-Fruit", "egnarO"))) }, - testServer(in_paths_out_string)((ps: Seq[String]) => pureResult(ps.mkString(" ").asRight[Unit])) { baseUri => + testServer(in_paths_out_string)((ps: Seq[String]) => pureResult(ps.mkString(" ").asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/hello/it/is/me/hal").send(backend).map(_.body shouldBe Right("hello it is me hal")) }, testServer(in_paths_out_string, "paths should match empty path")((ps: Seq[String]) => pureResult(ps.mkString(" ").asRight[Unit])) { - baseUri => basicRequest.get(uri"$baseUri").send(backend).map(_.body shouldBe Right("")) + (backend, baseUri) => basicRequest.get(uri"$baseUri").send(backend).map(_.body shouldBe Right("")) }, testServer(in_simple_multipart_out_multipart)((fa: FruitAmount) => pureResult(FruitAmount(fa.fruit + " apple", fa.amount * 2).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicStringRequest .post(uri"$baseUri/api/echo/multipart") .multipartBody(multipart("fruit", "pineapple"), multipart("amount", "120")) @@ -254,7 +257,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .header("X-Auth", fd.data.headers.find(_.is("X-Auth")).map(_.value).toString) ).asRight[Unit] ) - ) { baseUri => + ) { (backend, baseUri) => val file = writeToFile("peach mario") basicStringRequest .post(uri"$baseUri/api/echo/multipart") @@ -270,7 +273,7 @@ class ServerBasicTests[F[_], ROUTE, B]( pureResult( parts.map(part => s"${part.name}:${new String(part.body)}").mkString("\n").asRight[Unit] ) - ) { baseUri => + ) { (backend, baseUri) => val file1 = writeToFile("peach mario") val file2 = writeToFile("daisy luigi") basicStringRequest @@ -286,10 +289,11 @@ class ServerBasicTests[F[_], ROUTE, B]( r.body should include("file2:daisy luigi") } }, - testServer(in_query_out_string, "invalid query parameter")((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { baseUri => - basicRequest.get(uri"$baseUri?fruit2=orange").send(backend).map(_.code shouldBe StatusCode.BadRequest) + testServer(in_query_out_string, "invalid query parameter")((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri?fruit2=orange").send(backend).map(_.code shouldBe StatusCode.BadRequest) }, - testServer(in_query_list_out_header_list)((l: List[String]) => pureResult(("v0" :: l).reverse.asRight[Unit])) { baseUri => + testServer(in_query_list_out_header_list)((l: List[String]) => pureResult(("v0" :: l).reverse.asRight[Unit])) { (backend, baseUri) => basicRequest .get(uri"$baseUri/api/echo/param-to-header?qq=${List("v1", "v2", "v3")}") .send(backend) @@ -303,14 +307,14 @@ class ServerBasicTests[F[_], ROUTE, B]( }, testServer(in_cookies_out_cookies)((cs: List[sttp.model.headers.Cookie]) => pureResult(cs.map(c => CookieWithMeta.unsafeApply(c.name, c.value.reverse)).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/echo/headers").cookies(("c1", "v1"), ("c2", "v2")).send(backend).map { r => r.unsafeCookies.map(c => (c.name, c.value)).toList shouldBe List(("c1", "1v"), ("c2", "2v")) } }, testServer(in_set_cookie_value_out_set_cookie_value)((c: CookieValueWithMeta) => pureResult(c.copy(value = c.value.reverse).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/echo/headers").header("Set-Cookie", "c1=xy; HttpOnly; Path=/").send(backend).map { r => r.unsafeCookies.toList shouldBe List( CookieWithMeta.unsafeApply("c1", "yx", None, None, None, Some("/"), secure = false, httpOnly = true) @@ -318,25 +322,25 @@ class ServerBasicTests[F[_], ROUTE, B]( } }, testServer(in_string_out_content_type_string, "dynamic content type")((b: String) => pureResult((b, "image/png").asRight[Unit])) { - baseUri => + (backend, baseUri) => basicStringRequest.get(uri"$baseUri/api/echo").body("test").send(backend).map { r => r.contentType shouldBe Some("image/png") r.body shouldBe "test" } }, - testServer(in_content_type_out_string)((ct: String) => pureResult(ct.asRight[Unit])) { baseUri => + testServer(in_content_type_out_string)((ct: String) => pureResult(ct.asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/echo").contentType("application/dicom+json").send(backend).map { r => r.body shouldBe Right("application/dicom+json") } }, - testServer(in_content_type_fixed_header, "mismatch content-type")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(in_content_type_fixed_header, "mismatch content-type")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .contentType(MediaType.ApplicationXml) .send(backend) .map(_.code shouldBe StatusCode.UnsupportedMediaType) }, - testServer(in_content_type_fixed_header, "missing content-type")((_: Unit) => pureResult(().asRight[Unit])) { baseUri => + testServer(in_content_type_fixed_header, "missing content-type")((_: Unit) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .send(backend) @@ -344,7 +348,7 @@ class ServerBasicTests[F[_], ROUTE, B]( }, testServer(in_content_type_header_with_custom_decode_results, "mismatch content-type")((_: MediaType) => pureResult(Either.right[Unit, Unit](())) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .contentType(MediaType.ApplicationXml) @@ -353,27 +357,27 @@ class ServerBasicTests[F[_], ROUTE, B]( }, testServer(in_content_type_header_with_custom_decode_results, "missing content-type")((_: MediaType) => pureResult(Either.right[Unit, Unit](())) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .send(backend) .map(_.code shouldBe StatusCode.BadRequest) }, - testServer(in_unit_out_html)(_ => pureResult("".asRight[Unit])) { baseUri => + testServer(in_unit_out_html)(_ => pureResult("".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/echo").send(backend).map { r => r.contentType shouldBe Some("text/html; charset=UTF-8") } }, - testServer(in_unit_out_header_redirect)(_ => pureResult("http://new.com".asRight[Unit])) { baseUri => + testServer(in_unit_out_header_redirect)(_ => pureResult("http://new.com".asRight[Unit])) { (backend, baseUri) => basicRequest.followRedirects(false).get(uri"$baseUri").send(backend).map { r => r.code shouldBe StatusCode.PermanentRedirect r.header("Location") shouldBe Some("http://new.com") } }, - testServer(in_unit_out_fixed_header)(_ => pureResult(().asRight[Unit])) { baseUri => + testServer(in_unit_out_fixed_header)(_ => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri").send(backend).map { r => r.header("Location") shouldBe Some("Poland") } }, - testServer(in_optional_json_out_optional_json)((fa: Option[FruitAmount]) => pureResult(fa.asRight[Unit])) { baseUri => + testServer(in_optional_json_out_optional_json)((fa: Option[FruitAmount]) => pureResult(fa.asRight[Unit])) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .send(backend) @@ -388,30 +392,35 @@ class ServerBasicTests[F[_], ROUTE, B]( .map(_.body shouldBe Right("""{"fruit":"orange","amount":11}""")) }, // path matching - testServer(endpoint, "no path should match anything")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => + testServer(endpoint, "no path should match anything")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { (backend, baseUri) => basicRequest.get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri/").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri/nonemptypath").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri/nonemptypath/nonemptypath2").send(backend).map(_.code shouldBe StatusCode.Ok) }, - testServer(in_root_path, "root path should not match non-root path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => - basicRequest.get(uri"$baseUri/nonemptypath").send(backend).map(_.code shouldBe StatusCode.NotFound) + testServer(in_root_path, "root path should not match non-root path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri/nonemptypath").send(backend).map(_.code shouldBe StatusCode.NotFound) }, - testServer(in_root_path, "root path should match empty path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => - basicRequest.get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.Ok) + testServer(in_root_path, "root path should match empty path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.Ok) }, - testServer(in_root_path, "root path should match root path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => - basicRequest.get(uri"$baseUri/").send(backend).map(_.code shouldBe StatusCode.Ok) + testServer(in_root_path, "root path should match root path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri/").send(backend).map(_.code shouldBe StatusCode.Ok) }, - testServer(in_single_path, "single path should match single path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => - basicRequest.get(uri"$baseUri/api").send(backend).map(_.code shouldBe StatusCode.Ok) + testServer(in_single_path, "single path should match single path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri/api").send(backend).map(_.code shouldBe StatusCode.Ok) }, - testServer(in_single_path, "single path should match single/ path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => - basicRequest.get(uri"$baseUri/api/").send(backend).map(_.code shouldBe StatusCode.Ok) + testServer(in_single_path, "single path should match single/ path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri/api/").send(backend).map(_.code shouldBe StatusCode.Ok) }, testServer(in_path_paths_out_header_body, "Capturing paths after path capture") { case (i, paths) => pureResult(Right((i, paths.mkString(",")))) - } { baseUri => + } { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/15/and/some/more/path").send(backend).map { r => r.code shouldBe StatusCode.Ok r.header("IntPath") shouldBe Some("15") @@ -420,44 +429,46 @@ class ServerBasicTests[F[_], ROUTE, B]( }, testServer(in_path_paths_out_header_body, "Capturing paths after path capture (when empty)") { case (i, paths) => pureResult(Right((i, paths.mkString(",")))) - } { baseUri => + } { (backend, baseUri) => basicRequest.get(uri"$baseUri/api/15/and/").send(backend).map { r => r.code shouldBe StatusCode.Ok r.header("IntPath") shouldBe Some("15") r.body shouldBe Right("") } }, - testServer(in_single_path, "single path should not match root path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { baseUri => - basicRequest.get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.NotFound) >> - basicRequest.get(uri"$baseUri/").send(backend).map(_.code shouldBe StatusCode.NotFound) + testServer(in_single_path, "single path should not match root path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.NotFound) >> + basicRequest.get(uri"$baseUri/").send(backend).map(_.code shouldBe StatusCode.NotFound) }, testServer(in_single_path, "single path should not match larger path")((_: Unit) => pureResult(Either.right[Unit, Unit](()))) { - baseUri => + (backend, baseUri) => basicRequest.get(uri"$baseUri/api/echo/hello").send(backend).map(_.code shouldBe StatusCode.NotFound) >> basicRequest.get(uri"$baseUri/api/echo/").send(backend).map(_.code shouldBe StatusCode.NotFound) }, testServer(in_string_out_status_from_string)((v: String) => pureResult((if (v == "apple") Right("x") else Left(10)).asRight[Unit])) { - baseUri => + (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=apple").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.code shouldBe StatusCode.Accepted) }, - testServer(in_int_out_value_form_exact_match)((num: Int) => pureResult(if (num % 2 == 0) Right("A") else Right("B"))) { baseUri => - basicRequest.get(uri"$baseUri/mapping?num=1").send(backend).map(_.code shouldBe StatusCode.Ok) >> - basicRequest.get(uri"$baseUri/mapping?num=2").send(backend).map(_.code shouldBe StatusCode.Accepted) + testServer(in_int_out_value_form_exact_match)((num: Int) => pureResult(if (num % 2 == 0) Right("A") else Right("B"))) { + (backend, baseUri) => + basicRequest.get(uri"$baseUri/mapping?num=1").send(backend).map(_.code shouldBe StatusCode.Ok) >> + basicRequest.get(uri"$baseUri/mapping?num=2").send(backend).map(_.code shouldBe StatusCode.Accepted) }, testServer(in_string_out_status_from_string_one_empty)((v: String) => pureResult((if (v == "apple") Right("x") else Left(())).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=apple").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.code shouldBe StatusCode.Accepted) }, - testServer(in_extract_request_out_string)((v: String) => pureResult(v.asRight[Unit])) { baseUri => + testServer(in_extract_request_out_string)((v: String) => pureResult(v.asRight[Unit])) { (backend, baseUri) => basicStringRequest.get(uri"$baseUri").send(backend).map(_.body shouldBe "GET") >> basicStringRequest.post(uri"$baseUri").send(backend).map(_.body shouldBe "POST") }, testServer(in_string_out_status)((v: String) => pureResult((if (v == "apple") StatusCode.Accepted else StatusCode.NotFound).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=apple").send(backend).map(_.code shouldBe StatusCode.Accepted) >> basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.code shouldBe StatusCode.NotFound) }, @@ -466,7 +477,7 @@ class ServerBasicTests[F[_], ROUTE, B]( in_path_fixed_capture_fixed_capture, "Returns 400 if path 'shape' matches, but failed to parse a path parameter", Some(decodeFailureHandlerBadRequestOnPathFailure) - )(_ => pureResult(Either.right[Unit, Unit](()))) { baseUri => + )(_ => pureResult(Either.right[Unit, Unit](()))) { (backend, baseUri) => basicRequest.get(uri"$baseUri/customer/asd/orders/2").send(backend).map { response => response.body shouldBe Left("Invalid value for: path parameter customer_id") response.code shouldBe StatusCode.BadRequest @@ -476,7 +487,7 @@ class ServerBasicTests[F[_], ROUTE, B]( in_path_fixed_capture_fixed_capture, "Returns 404 if path 'shape' doesn't match", Some(decodeFailureHandlerBadRequestOnPathFailure) - )(_ => pureResult(Either.right[Unit, Unit](()))) { baseUri => + )(_ => pureResult(Either.right[Unit, Unit](()))) { (backend, baseUri) => basicRequest.get(uri"$baseUri/customer").send(backend).map(response => response.code shouldBe StatusCode.NotFound) >> basicRequest.get(uri"$baseUri/customer/asd").send(backend).map(response => response.code shouldBe StatusCode.NotFound) >> basicRequest @@ -485,13 +496,13 @@ class ServerBasicTests[F[_], ROUTE, B]( .map(response => response.code shouldBe StatusCode.NotFound) }, // auth - testServer(in_auth_apikey_header_out_string)((s: String) => pureResult(s.asRight[Unit])) { baseUri => + testServer(in_auth_apikey_header_out_string)((s: String) => pureResult(s.asRight[Unit])) { (backend, baseUri) => basicStringRequest.get(uri"$baseUri/auth").header("X-Api-Key", "1234").send(backend).map(_.body shouldBe "1234") }, - testServer(in_auth_apikey_query_out_string)((s: String) => pureResult(s.asRight[Unit])) { baseUri => + testServer(in_auth_apikey_query_out_string)((s: String) => pureResult(s.asRight[Unit])) { (backend, baseUri) => basicStringRequest.get(uri"$baseUri/auth?api-key=1234").send(backend).map(_.body shouldBe "1234") }, - testServer(in_auth_basic_out_string)((up: UsernamePassword) => pureResult(up.toString.asRight[Unit])) { baseUri => + testServer(in_auth_basic_out_string)((up: UsernamePassword) => pureResult(up.toString.asRight[Unit])) { (backend, baseUri) => basicStringRequest .get(uri"$baseUri/auth") .auth @@ -499,7 +510,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .send(backend) .map(_.body shouldBe "UsernamePassword(teddy,Some(bear))") }, - testServer(in_auth_bearer_out_string)((s: String) => pureResult(s.asRight[Unit])) { baseUri => + testServer(in_auth_bearer_out_string)((s: String) => pureResult(s.asRight[Unit])) { (backend, baseUri) => basicStringRequest.get(uri"$baseUri/auth").auth.bearer("1234").send(backend).map(_.body shouldBe "1234") }, // @@ -509,7 +520,7 @@ class ServerBasicTests[F[_], ROUTE, B]( route(endpoint.get.in("p1").out(stringBody).serverLogic((_: Unit) => pureResult("e1".asRight[Unit]))), route(endpoint.get.in("p1" / "p2").out(stringBody).serverLogic((_: Unit) => pureResult("e2".asRight[Unit]))) ) - ) { baseUri => + ) { (backend, baseUri) => basicStringRequest.get(uri"$baseUri/p1").send(backend).map(_.body shouldBe "e1") >> basicStringRequest.get(uri"$baseUri/p1/p2").send(backend).map(_.body shouldBe "e2") }, @@ -531,7 +542,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .serverLogic((s: Array[Byte]) => pureResult(s"p2 ${s.length}".asRight[Unit])) ) ) - ) { baseUri => + ) { (backend, baseUri) => basicStringRequest .post(uri"$baseUri/p2") .body("a" * 1000000) @@ -544,7 +555,7 @@ class ServerBasicTests[F[_], ROUTE, B]( route(endpoint.get.in(query[String]("q1")).in("p1").serverLogic((_: String) => pureResult(().asRight[Unit]))), route(endpoint.get.in(query[String]("q2")).in("p2").serverLogic((_: String) => pureResult(().asRight[Unit]))) ) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri/p1?q1=10").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri/p1?q2=10").send(backend).map(_.code shouldBe StatusCode.BadRequest) >> basicRequest.get(uri"$baseUri/p2?q2=10").send(backend).map(_.code shouldBe StatusCode.Ok) >> @@ -556,7 +567,7 @@ class ServerBasicTests[F[_], ROUTE, B]( route(endpoint.get.in("p1").in(query[String]("q1")).out(stringBody).serverLogic((_: String) => pureResult("e1".asRight[Unit]))), route(endpoint.get.in("p1" / "p2").out(stringBody).serverLogic((_: Unit) => pureResult("e2".asRight[Unit]))) ) - ) { baseUri => basicStringRequest.get(uri"$baseUri/p1/p2").send(backend).map(_.body shouldBe "e2") }, + ) { (backend, baseUri) => basicStringRequest.get(uri"$baseUri/p1/p2").send(backend).map(_.body shouldBe "e2") }, testServer( "two endpoints with validation: should not try the second one if validation fails", NonEmptyList.of( @@ -565,12 +576,12 @@ class ServerBasicTests[F[_], ROUTE, B]( ), route(endpoint.get.in("p2").serverLogic((_: Unit) => pureResult(().asRight[Unit]))) ) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri/p1/abcde").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri/p1/ab").send(backend).map(_.code shouldBe StatusCode.BadRequest) >> basicRequest.get(uri"$baseUri/p2").send(backend).map(_.code shouldBe StatusCode.Ok) }, - testServer(in_header_out_header_unit_extended)(in => pureResult(in.asRight[Unit])) { baseUri => + testServer(in_header_out_header_unit_extended)(in => pureResult(in.asRight[Unit])) { (backend, baseUri) => basicRequest .get(uri"$baseUri") .header("A", "1") @@ -578,31 +589,31 @@ class ServerBasicTests[F[_], ROUTE, B]( .send(backend) .map(_.headers.map(h => h.name -> h.value).toSet should contain allOf ("Y" -> "3", "B" -> "2")) }, - testServer(in_4query_out_4header_extended)(in => pureResult(in.asRight[Unit])) { baseUri => + testServer(in_4query_out_4header_extended)(in => pureResult(in.asRight[Unit])) { (backend, baseUri) => basicRequest .get(uri"$baseUri?a=1&b=2&x=3&y=4") .send(backend) .map(_.headers.map(h => h.name -> h.value).toSet should contain allOf ("A" -> "1", "B" -> "2", "X" -> "3", "Y" -> "4")) }, - testServer(in_3query_out_3header_mapped_to_tuple)(in => pureResult(in.asRight[Unit])) { baseUri => + testServer(in_3query_out_3header_mapped_to_tuple)(in => pureResult(in.asRight[Unit])) { (backend, baseUri) => basicRequest .get(uri"$baseUri?p1=1&p2=2&p3=3") .send(backend) .map(_.headers.map(h => h.name -> h.value).toSet should contain allOf ("P1" -> "1", "P2" -> "2", "P3" -> "3")) }, - testServer(in_2query_out_2query_mapped_to_unit)(in => pureResult(in.asRight[Unit])) { baseUri => + testServer(in_2query_out_2query_mapped_to_unit)(in => pureResult(in.asRight[Unit])) { (backend, baseUri) => basicRequest .get(uri"$baseUri?p1=1&p2=2") .send(backend) .map(_.headers.map(h => h.name -> h.value).toSet should contain allOf ("P1" -> "DEFAULT_HEADER", "P2" -> "2")) }, - testServer(in_query_with_default_out_string)(in => pureResult(in.asRight[Unit])) { baseUri => + testServer(in_query_with_default_out_string)(in => pureResult(in.asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri?p1=x").send(backend).map(_.body shouldBe Right("x")) >> basicRequest.get(uri"$baseUri").send(backend).map(_.body shouldBe Right("DEFAULT")) }, testServer(out_json_or_default_json)(entityType => pureResult((if (entityType == "person") Person("mary", 20) else Organization("work")).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri/entity/person").send(backend).map { r => r.code shouldBe StatusCode.Created r.body.right.get should include("mary") @@ -613,10 +624,10 @@ class ServerBasicTests[F[_], ROUTE, B]( } }, // - testServer(endpoint, "handle exceptions")(_ => throw new RuntimeException()) { baseUri => + testServer(endpoint, "handle exceptions")(_ => throw new RuntimeException()) { (backend, baseUri) => basicRequest.get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.InternalServerError) }, - testServer(out_json_xml_text_common_schema)(_ => pureResult(Organization("sml").asRight[Unit])) { baseUri => + testServer(out_json_xml_text_common_schema)(_ => pureResult(Organization("sml").asRight[Unit])) { (backend, baseUri) => def ok(body: String) = (StatusCode.Ok, body.asRight[String]) def unsupportedMediaType() = (StatusCode.UnsupportedMediaType, "".asLeft[String]) def badRequest() = (StatusCode.BadRequest, "".asLeft[String]) @@ -659,15 +670,16 @@ class ServerBasicTests[F[_], ROUTE, B]( } }) }, - testServer(in_root_path, testNameSuffix = "accepts header without output body")(_ => pureResult(().asRight[Unit])) { baseUri => - basicRequest.header(HeaderNames.Accept, "text/plain").get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.Ok) + testServer(in_root_path, testNameSuffix = "accepts header without output body")(_ => pureResult(().asRight[Unit])) { + (backend, baseUri) => + basicRequest.header(HeaderNames.Accept, "text/plain").get(uri"$baseUri").send(backend).map(_.code shouldBe StatusCode.Ok) }, testServer( "recover errors from exceptions", NonEmptyList.of( routeRecoverErrors(endpoint.in(query[String]("name")).errorOut(jsonBody[FruitError]).out(stringBody), throwFruits) ) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?name=apple").send(backend).map(_.body shouldBe Right("ok")) >> basicRequest.get(uri"$baseUri?name=banana").send(backend).map { r => r.code shouldBe StatusCode.BadRequest @@ -679,18 +691,18 @@ class ServerBasicTests[F[_], ROUTE, B]( } }, testServer(Validation.in_query_tagged, "support query validation with tagged type")((_: String) => pureResult(().asRight[Unit])) { - baseUri => + (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=apple").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri?fruit=orange").send(backend).map(_.code shouldBe StatusCode.BadRequest) >> basicRequest.get(uri"$baseUri?fruit=banana").send(backend).map(_.code shouldBe StatusCode.Ok) }, - testServer(Validation.in_query, "support query validation")((_: Int) => pureResult(().asRight[Unit])) { baseUri => + testServer(Validation.in_query, "support query validation")((_: Int) => pureResult(().asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri?amount=3").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri?amount=-3").send(backend).map(_.code shouldBe StatusCode.BadRequest) }, testServer(Validation.in_valid_json, "support jsonBody validation with wrapped type")((_: ValidFruitAmount) => pureResult(().asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri").body("""{"fruit":"orange","amount":11}""").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest .get(uri"$baseUri") @@ -700,14 +712,14 @@ class ServerBasicTests[F[_], ROUTE, B]( basicRequest.get(uri"$baseUri").body("""{"fruit":"orange","amount":1}""").send(backend).map(_.code shouldBe StatusCode.Ok) }, testServer(Validation.in_valid_query, "support query validation with wrapper type")((_: IntWrapper) => pureResult(().asRight[Unit])) { - baseUri => + (backend, baseUri) => basicRequest.get(uri"$baseUri?amount=11").send(backend).map(_.code shouldBe StatusCode.Ok) >> basicRequest.get(uri"$baseUri?amount=0").send(backend).map(_.code shouldBe StatusCode.BadRequest) >> basicRequest.get(uri"$baseUri?amount=1").send(backend).map(_.code shouldBe StatusCode.Ok) }, testServer(Validation.in_valid_json_collection, "support jsonBody validation with list of wrapped type")((_: BasketOfFruits) => pureResult(().asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .get(uri"$baseUri") .body("""{"fruits":[{"fruit":"orange","amount":11}]}""") @@ -729,7 +741,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .out(plainBody[Int]) .serverLogic { case (x, y) => pureResult((x * y.toInt).asRight[Unit]) }, "partial server logic - current, one part" - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?x=2&y=3").send(backend).map(_.body shouldBe Right("6")) }, testServerLogic( @@ -742,7 +754,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .out(plainBody[Long]) .serverLogic { case ((x, y), z) => pureResult((x * y * z.toLong).asRight[Unit]) }, "partial server logic - current, two parts" - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?x=2&y=3&z=5").send(backend).map(_.body shouldBe Right("30")) }, testServerLogic( @@ -755,7 +767,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .out(plainBody[Int]) .serverLogic { case (xy, (z, u)) => pureResult((xy * z.toInt * u.toInt).asRight[Unit]) }, "partial server logic - current, one part, multiple values" - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?x=2&y=3&z=5&u=7").send(backend).map(_.body shouldBe Right("175")) }, testServerLogic( @@ -766,7 +778,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .serverLogicPart((x: String) => pureResult(x.toInt.asRight[Unit])) .andThen { case (x, y) => pureResult((x * y.toInt).asRight[Unit]) }, "partial server logic - parts, one part" - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?x=2&y=3").send(backend).map(_.body shouldBe Right("6")) }, testServerLogic( @@ -779,7 +791,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .andThenPart((y: String) => pureResult(y.toLong.asRight[Unit])) .andThen { case ((x, y), z) => pureResult((x * y * z.toLong).asRight[Unit]) }, "partial server logic - parts, two parts" - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?x=2&y=3&z=5").send(backend).map(_.body shouldBe Right("30")) }, testServerLogic( @@ -792,7 +804,7 @@ class ServerBasicTests[F[_], ROUTE, B]( .serverLogicPart { t: (String, String) => pureResult((t._1.toInt + t._2.toInt).asRight[Unit]) } .andThen { case (xy, (z, u)) => pureResult((xy * z.toInt * u.toInt).asRight[Unit]) }, "partial server logic - parts, one part, multiple values" - ) { baseUri => + ) { (backend, baseUri) => basicRequest.get(uri"$baseUri?x=2&y=3&z=5&u=7").send(backend).map(_.body shouldBe Right("175")) } ) @@ -804,7 +816,7 @@ class ServerBasicTests[F[_], ROUTE, B]( Part("", fd.data.body, fd.data.otherDispositionParams, fd.data.headers) ).asRight[Unit] ) - ) { baseUri => + ) { (backend, baseUri) => val file = writeToFile("peach mario") basicStringRequest .post(uri"$baseUri/api/echo/multipart") @@ -820,9 +832,9 @@ class ServerBasicTests[F[_], ROUTE, B]( def inputStreamTests(): List[Test] = List( testServer(in_input_stream_out_input_stream)((is: InputStream) => pureResult((new ByteArrayInputStream(inputStreamToByteArray(is)): InputStream).asRight[Unit]) - ) { baseUri => basicRequest.post(uri"$baseUri/api/echo").body("mango").send(backend).map(_.body shouldBe Right("mango")) }, + ) { (backend, baseUri) => basicRequest.post(uri"$baseUri/api/echo").body("mango").send(backend).map(_.body shouldBe Right("mango")) }, testServer(in_string_out_stream_with_header)(_ => pureResult(Right((new ByteArrayInputStream(Array.fill[Byte](128)(0)), Some(128))))) { - baseUri => + (backend, baseUri) => basicRequest.post(uri"$baseUri/api/echo").body("test string body").response(asByteArray).send(backend).map { r => r.body.map(_.length) shouldBe Right(128) r.body.map(_.foreach(b => b shouldBe 0)) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala index a5ee77ddbe..916bf86753 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala @@ -1,10 +1,9 @@ package sttp.tapir.server.tests -import cats.effect.IO import cats.implicits._ import org.scalatest.concurrent.Eventually.eventually import org.scalatest.matchers.should.Matchers._ -import sttp.client3.{SttpBackend, _} +import sttp.client3._ import sttp.monad.MonadError import sttp.monad.syntax._ import sttp.tapir.metrics.{EndpointMetric, Metric} @@ -16,11 +15,8 @@ import sttp.tapir.tests.{Test, _} import java.io.{ByteArrayInputStream, InputStream} import java.util.concurrent.atomic.AtomicInteger -class ServerMetricsTest[F[_], ROUTE, B]( - backend: SttpBackend[IO, Any], - createServerTest: CreateServerTest[F, Any, ROUTE, B] -)(implicit m: MonadError[F]) { - import createServerTest._ +class ServerMetricsTest[F[_], ROUTE, B](createTestServer: TestServer[F, Any, ROUTE, B])(implicit m: MonadError[F]) { + import createTestServer._ def tests(): List[Test] = List( { @@ -30,7 +26,7 @@ class ServerMetricsTest[F[_], ROUTE, B]( testServer(in_json_out_json.name("metrics"), metricsInterceptor = metrics.some)(f => (if (f.fruit == "apple") Right(f) else Left(())).unit - ) { baseUri => + ) { (backend, baseUri) => basicRequest // onDecodeSuccess path .post(uri"$baseUri/api/echo") .body("""{"fruit":"apple","amount":1}""") @@ -56,7 +52,7 @@ class ServerMetricsTest[F[_], ROUTE, B]( testServer(in_input_stream_out_input_stream.name("metrics"), metricsInterceptor = metrics.some)(is => (new ByteArrayInputStream(inputStreamToByteArray(is)): InputStream).asRight[Unit].unit - ) { baseUri => + ) { (backend, baseUri) => basicRequest .post(uri"$baseUri/api/echo") .body("okoń") @@ -72,7 +68,7 @@ class ServerMetricsTest[F[_], ROUTE, B]( val resCounter = newResponseCounter[F] val metrics = new MetricsRequestInterceptor[F, B](List(resCounter), Seq.empty) - testServer(in_root_path.name("metrics"), metricsInterceptor = metrics.some)(_ => ().asRight[Unit].unit) { baseUri => + testServer(in_root_path.name("metrics"), metricsInterceptor = metrics.some)(_ => ().asRight[Unit].unit) { (backend, baseUri) => basicRequest .get(uri"$baseUri") .send(backend) @@ -91,7 +87,7 @@ class ServerMetricsTest[F[_], ROUTE, B]( testServer(in_root_path.name("metrics on exception"), metricsInterceptor = metrics.some) { _ => Thread.sleep(100) throw new RuntimeException("Ups") - } { baseUri => + } { (backend, baseUri) => basicRequest .get(uri"$baseUri") .send(backend) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala index 0685e23632..3b6269634b 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala @@ -1,42 +1,45 @@ package sttp.tapir.server.tests -import cats.effect.IO -import sttp.capabilities.Streams -import sttp.client3._ -import sttp.tapir.tests.{Test, in_stream_out_stream, in_stream_out_stream_with_content_length} import cats.syntax.all._ -import sttp.monad.MonadError import org.scalatest.matchers.should.Matchers._ +import sttp.capabilities.Streams +import sttp.client3._ import sttp.model.{Header, HeaderNames} +import sttp.monad.MonadError +import sttp.tapir.tests.{Test, in_stream_out_stream, in_stream_out_stream_with_content_length} -class ServerStreamingTests[F[_], S, ROUTE, B](backend: SttpBackend[IO, Any], serverTests: CreateServerTest[F, S, ROUTE, B], streams: Streams[S])( - implicit m: MonadError[F] +class ServerStreamingTests[F[_], S, ROUTE, B](createTestServer: TestServer[F, S, ROUTE, B], streams: Streams[S])(implicit + m: MonadError[F] ) { private def pureResult[T](t: T): F[T] = m.unit(t) def tests(): List[Test] = { - import serverTests._ + import createTestServer._ val penPineapple = "pen pineapple apple pen" List( - testServer(in_stream_out_stream(streams))((s: streams.BinaryStream) => pureResult(s.asRight[Unit])) { baseUri => + testServer(in_stream_out_stream(streams))((s: streams.BinaryStream) => pureResult(s.asRight[Unit])) { (backend, baseUri) => basicRequest.post(uri"$baseUri/api/echo").body(penPineapple).send(backend).map(_.body shouldBe Right(penPineapple)) }, testServer( in_stream_out_stream_with_content_length(streams) - )((in: (Long, streams.BinaryStream)) => pureResult(in.asRight[Unit])) { baseUri => + )((in: (Long, streams.BinaryStream)) => pureResult(in.asRight[Unit])) { (backend, baseUri) => { - basicRequest.post(uri"$baseUri/api/echo").contentLength(penPineapple.length.toLong).body(penPineapple).send(backend).map { - response => + basicRequest + .post(uri"$baseUri/api/echo") + .contentLength(penPineapple.length.toLong) + .body(penPineapple) + .send(backend) + .map { response => response.body shouldBe Right(penPineapple) if (response.headers.contains(Header(HeaderNames.TransferEncoding, "chunked"))) { response.contentLength shouldBe None } else { response.contentLength shouldBe Some(penPineapple.length) } - } + } } } ) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala index 12ca4ca590..740cde89e5 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala @@ -4,7 +4,6 @@ import cats.effect.IO import cats.syntax.all._ import io.circe.generic.auto._ import org.scalatest.matchers.should.Matchers._ -import sttp.capabilities.fs2.Fs2Streams import sttp.capabilities.{Streams, WebSockets} import sttp.client3._ import sttp.monad.MonadError @@ -17,13 +16,13 @@ import sttp.tapir.tests.{Fruit, Test} import sttp.ws.{WebSocket, WebSocketFrame} abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( - backend: SttpBackend[IO, Fs2Streams[IO] with WebSockets], - createServerTest: CreateServerTest[F, S with WebSockets, ROUTE, B], + // backend: SttpBackend[IO, Fs2Streams[IO] with WebSockets], + createTestServer: TestServer[F, S with WebSockets, ROUTE, B], val streams: S )(implicit m: MonadError[F] ) { - import createServerTest._ + import createTestServer._ private def pureResult[T](t: T): F[T] = m.unit(t) def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] @@ -35,7 +34,7 @@ abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( testServer( endpoint.out(stringWs), "string client-terminated echo" - )((_: Unit) => pureResult(stringEcho.asRight[Unit])) { baseUri => + )((_: Unit) => pureResult(stringEcho.asRight[Unit])) { (backend, baseUri) => basicRequest .response(asWebSocket { ws: WebSocket[IO] => for { @@ -56,7 +55,7 @@ abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( testServer(endpoint.out(stringWs).name("metrics"), metricsInterceptor = metrics.some)((_: Unit) => pureResult(stringEcho.asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .response(asWebSocket { ws: WebSocket[IO] => for { @@ -75,7 +74,7 @@ abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( }, testServer(endpoint.out(webSocketBody[Fruit, CodecFormat.Json, Fruit, CodecFormat.Json](streams)), "json client-terminated echo")( (_: Unit) => pureResult(functionToPipe((f: Fruit) => Fruit(s"echo: ${f.f}")).asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .response(asWebSocket { ws: WebSocket[IO] => for { @@ -97,7 +96,7 @@ abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( case "end" => None case msg => Some(s"echo: $msg") }.asRight[Unit]) - ) { baseUri => + ) { (backend, baseUri) => basicRequest .response(asWebSocket { ws: WebSocket[IO] => for { @@ -123,7 +122,7 @@ abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( .errorOut(stringBody) .out(stringWs), "non web-socket request" - )(isWS => if (isWS) pureResult(stringEcho.asRight) else pureResult("Not a WS!".asLeft)) { baseUri => + )(isWS => if (isWS) pureResult(stringEcho.asRight) else pureResult("Not a WS!".asLeft)) { (backend, baseUri) => basicRequest .response(asString) .get(baseUri.scheme("http")) diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala index d215029ab9..6ba571d283 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala @@ -4,7 +4,7 @@ import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.capabilities.fs2.Fs2Streams import sttp.monad.MonadError -import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, ServerStreamingTests, CreateServerTest, backendResource} +import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, ServerStreamingTests, CreateTestServer, backendResource} import sttp.tapir.tests.{Test, TestSuite} class CatsVertxServerTest extends TestSuite { @@ -17,7 +17,7 @@ class CatsVertxServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: MonadError[IO] = VertxCatsServerInterpreter.monadError[IO] val interpreter = new CatsVertxTestServerInterpreter(vertx) - val createServerTest = new CreateServerTest(interpreter) + val createServerTest = new CreateTestServer(interpreter) new ServerBasicTests( backend, diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala index fc4e68742b..8875d785d1 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.vertx import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, CreateServerTest, backendResource} +import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, CreateTestServer, backendResource} import sttp.tapir.tests.{Test, TestSuite} import scala.concurrent.ExecutionContext @@ -16,7 +16,7 @@ class VertxBlockingServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: FutureMonad = new FutureMonad()(ExecutionContext.global) val interpreter = new VertxTestServerBlockingInterpreter(vertx) - val createServerTest = new CreateServerTest(interpreter) + val createServerTest = new CreateTestServer(interpreter) new ServerBasicTests( backend, diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala index e290a152b1..5a089e6de5 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.vertx import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{CreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} import sttp.tapir.tests.{Test, TestSuite} import scala.concurrent.ExecutionContext @@ -17,15 +17,15 @@ class VertxServerTest extends TestSuite { implicit val m: FutureMonad = new FutureMonad()(ExecutionContext.global) val interpreter = new VertxTestServerInterpreter(vertx) - val createServerTest = new CreateServerTest(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) new ServerBasicTests( - backend, - createServerTest, + createTestServer, interpreter, multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong - ).tests() ++ new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerMetricsTest(backend, createServerTest).tests() + ).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerMetricsTest(createTestServer).tests() } } } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala index d479439ba5..dc2f462f52 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala @@ -4,7 +4,7 @@ import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.capabilities.zio.ZioStreams import sttp.monad.MonadError -import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, ServerStreamingTests, CreateServerTest, backendResource} +import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, ServerStreamingTests, CreateTestServer, backendResource} import sttp.tapir.tests.{Test, TestSuite} import zio.interop.catz._ import zio.Task @@ -20,7 +20,7 @@ class ZioVertxServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: MonadError[Task] = VertxZioServerInterpreter.monadError val interpreter = new ZioVertxTestServerInterpreter(vertx) - val createServerTest = new CreateServerTest(interpreter) + val createServerTest = new CreateTestServer(interpreter) new ServerBasicTests( backend, diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 62f16cde28..2bfaac41f4 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -16,6 +16,9 @@ object LambdaHandler extends RequestStreamHandler { override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() + + allEndpoints.foreach(e => println(e.endpoint.showDetail)) + val route: Route[IO] = AwsServerInterpreter.toRoute(allEndpoints.toList) val json = new String(input.readAllBytes(), StandardCharsets.UTF_8) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala index 2226a296eb..cd2de7e53b 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala @@ -9,9 +9,6 @@ import sttp.tapir.server.ServerEndpoint import sttp.tapir.tests._ package object tests { - val empty_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.serverLogic(_ => IO.pure(().asRight[Unit])) - - val empty_get_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.get.serverLogic(_ => IO.pure(().asRight[Unit])) val in_path_path_out_string_endpoint: ServerEndpoint[(String, Port), Unit, String, Any, IO] = in_path_path_out_string.serverLogic { case (fruit: String, amount: Int) => IO.pure(s"$fruit $amount".asRight[Unit]) diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala index 0194fc3c4d..369a5351c4 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala @@ -18,25 +18,11 @@ class AwsLambdaHttpTest extends AnyFunSuite { private val baseUri: Uri = uri"http://localhost:3000" - testServer(empty_endpoint, "GET empty endpoint") { backend => - basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) - } - - testServer(empty_endpoint, "POST empty endpoint") { backend => - basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) - } - - testServer(empty_get_endpoint, "GET a GET endpoint") { backend => - basicRequest.get(baseUri).send(backend).map(_.body shouldBe Right("")) - } - - testServer(empty_get_endpoint, "POST a GET endpoint") { backend => - basicRequest.post(baseUri).send(backend).map(_.body shouldBe Right("")) - } - testServer(in_path_path_out_string_endpoint) { backend => basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map { req => - req.body.map(b => decode(b) shouldBe "orange 20").getOrElse(Assertions.fail()) + req.body + .map(b => decode(b) shouldBe "orange 20") + .getOrElse(Assertions.fail()) } } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala new file mode 100644 index 0000000000..128e494bf8 --- /dev/null +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala @@ -0,0 +1,17 @@ +package sttp.tapir.serverless.aws.lambda.tests + +import cats.effect.{IO, Resource} +import sttp.tapir.server.tests.ServerBasicTests +import sttp.tapir.serverless.aws.lambda.Route +import sttp.tapir.serverless.aws.lambda.tests.LambdaStubTestServer._ +import sttp.tapir.tests.{Test, TestSuite} + +class AwsLambdaStubHttpTest extends TestSuite { + override def tests: Resource[IO, List[Test]] = Resource.eval { + IO.pure { + val interpreter = new AwsLambdaTestServerInterpreter + val createTestServer = new LambdaStubTestServer + new ServerBasicTests[IO, Route[IO], String](createTestServer, interpreter)(catsMonadIO).tests() + } + } +} diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala new file mode 100644 index 0000000000..5d864ac3d9 --- /dev/null +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala @@ -0,0 +1,35 @@ +package sttp.tapir.serverless.aws.lambda.tests + +import cats.data.NonEmptyList +import cats.effect.{IO, Resource} +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor +import sttp.tapir.server.tests.TestServerInterpreter +import sttp.tapir.serverless.aws.lambda.{AwsServerInterpreter, AwsServerOptions, Route} +import sttp.tapir.tests.Port + +import scala.reflect.ClassTag + +class AwsLambdaTestServerInterpreter extends TestServerInterpreter[IO, Any, Route[IO], String] { + + override def route[I, E, O]( + e: ServerEndpoint[I, E, O, Any, IO], + decodeFailureHandler: Option[DecodeFailureHandler], + metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] + ): Route[IO] = { + implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( + metricsInterceptor = metricsInterceptor, + decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) + ) + AwsServerInterpreter.toRoute(e) + } + + override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit eClassTag: ClassTag[E]): Route[IO] = { + implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + AwsServerInterpreter.toRouteRecoverErrors(e)(fn) + } + + override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = ??? +} diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala new file mode 100644 index 0000000000..fc3183460e --- /dev/null +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala @@ -0,0 +1,121 @@ +package sttp.tapir.serverless.aws.lambda.tests + +import cats.data.NonEmptyList +import cats.effect.IO +import org.scalatest.{Assertion, Assertions} +import sttp.client3 +import sttp.client3.testing.SttpBackendStub +import sttp.client3.{ByteArrayBody, ByteBufferBody, InputStreamBody, NoBody, Request, Response, StringBody, SttpBackend, _} +import sttp.model.{Header, StatusCode, Uri} +import sttp.tapir.Endpoint +import sttp.tapir.integ.cats.CatsMonadError +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor +import sttp.tapir.server.stub._ +import sttp.tapir.server.tests.TestServer +import sttp.tapir.serverless.aws.lambda._ +import sttp.tapir.serverless.aws.lambda.tests.LambdaStubTestServer._ +import sttp.tapir.tests.Test + +import java.util.Base64 +import scala.util.Random + +class LambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { + + override def testServer[I, E, O]( + e: Endpoint[I, E, O, Any], + testNameSuffix: String, + decodeFailureHandler: Option[DecodeFailureHandler], + metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] + )(fn: I => IO[Either[E, O]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { + implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( + metricsInterceptor = metricsInterceptor, + decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) + ) + val se: ServerEndpoint[I, E, O, Any, IO] = e.serverLogic(fn) + val route: Route[IO] = AwsServerInterpreter.toRoute(se) + val backend: SttpBackendStub[IO, Any] = + SttpBackendStub(catsMonadIO) + .whenRequestMatchesEndpointThenInterpret( + e, + request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) + ) + .whenAnyRequest + .thenRespondNotFound() + + val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) + + Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) + } + + override def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, Any, IO], testNameSuffix: String)( + runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion] + ): Test = { + implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + val route: Route[IO] = AwsServerInterpreter.toRoute(e) + val backend = + SttpBackendStub(catsMonadIO).whenRequestMatchesEndpointThenInterpret( + e.endpoint, + request => { + val awsReq = sttpToAwsRequest(request) + route(awsReq).map { awsRes => + println(awsReq) + println(awsRes) + val sttpRes = awsToSttpResponse(awsRes) + println(sttpRes) + sttpRes + } +// route(sttpToAwsRequest(request)).map(awsToSttpResponse) + } + ) + + val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) + + Test(name)(() => runTest(backend, uri"http://localhost:3000").unsafeRunSync()) + } + + override def testServer(name: String, rs: => NonEmptyList[Route[IO]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { + Test("fail " + Random.nextInt())(() => Assertions.fail()) + } +} + +object LambdaStubTestServer { + implicit val catsMonadIO: CatsMonadError[IO] = new CatsMonadError[IO] + + def sttpToAwsRequest(request: Request[_, _]): AwsRequest = { + println("CALLING: " + request.uri.toJavaUri.toString) + AwsRequest( + rawPath = request.uri.pathSegments.toString, + rawQueryString = request.uri.params.toMultiSeq.foldLeft("") { case (q, (name, values)) => s"$q$name=${values.mkString(",")}" }, + headers = request.headers.map(h => h.name -> h.value).toMap, + requestContext = AwsRequestContext( + domainName = Some("localhost:3000"), + http = AwsHttp( + request.method.method, + request.uri.path.mkString("/"), + "http", + "127.0.0.1", + "Internet Explorer" + ) + ), + Some(request.body match { + case NoBody => "" + case StringBody(b, encoding, _) => new String(b) + case ByteArrayBody(b, _) => new String(b) + case ByteBufferBody(b, _) => new String(b.array()) + case InputStreamBody(b, _) => new String(b.readAllBytes()) + case _ => throw new UnsupportedOperationException + }), + isBase64Encoded = false + ) + } + + def awsToSttpResponse(response: AwsResponse): Response[String] = + client3.Response( + new String(Base64.getDecoder.decode(response.body)), + new StatusCode(response.statusCode), + "", + response.headers.map { case (n, v) => Header(n, v) }.toSeq + ) +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala index 5a6cf2e353..46f44b19ea 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala @@ -8,15 +8,18 @@ import java.net.{InetSocketAddress, URLDecoder} private[lambda] class AwsServerRequest(request: AwsRequest) extends ServerRequest { private val sttpUri: Uri = { val queryString = if (request.rawQueryString.nonEmpty) "?" + request.rawQueryString else "" - Uri.unsafeParse(s"$protocol://${request.requestContext.domainName}${request.rawPath}$queryString") + val uri = Uri.unsafeParse(s"$protocol://${request.requestContext.domainName.getOrElse("")}${request.rawPath}$queryString") + println("GOT " + uri.toJavaUri.toString) + uri } override def protocol: String = request.headers.getOrElse("x-forwarded-proto", "http") override def connectionInfo: ConnectionInfo = ConnectionInfo(None, Some(InetSocketAddress.createUnresolved(request.requestContext.http.sourceIp, 80)), None) override def underlying: Any = request - override def pathSegments: List[String] = + override def pathSegments: List[String] = { request.rawPath.dropWhile(_ == '/').split("/").toList.map(value => URLDecoder.decode(value, "UTF-8")) + } override def queryParameters: QueryParams = sttpUri.params override def method: Method = Method.unsafeApply(request.requestContext.http.method) override def uri: Uri = sttpUri From 5bcc9ac0a2d5980cb6b4edf5f43ca2f56f83d232 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 13 May 2021 12:14:10 +0200 Subject: [PATCH 06/35] file/multipart tests separated --- .../server/akkahttp/AkkaHttpServerTest.scala | 17 +-- .../finatra/cats/FinatraServerCatsTests.scala | 9 +- .../server/finatra/FinatraServerTest.scala | 10 +- .../server/http4s/Http4sServerTest.scala | 3 +- .../tapir/server/play/PlayServerTest.scala | 13 +- .../tests/ServerAuthenticationTests.scala | 1 - .../tapir/server/tests/ServerBasicTests.scala | 83 +------------ .../tests/ServerFileMutltipartTests.scala | 117 ++++++++++++++++++ .../server/tests/ServerWebSocketTests.scala | 1 - .../server/vertx/CatsVertxServerTest.scala | 19 ++- .../vertx/VertxBlockingServerTest.scala | 16 +-- .../tapir/server/vertx/VertxServerTest.scala | 19 ++- .../server/vertx/ZioVertxServerTest.scala | 30 +++-- .../lambda/tests/AwsLambdaStubHttpTest.scala | 48 +++++-- ...er.scala => AwsLambdaStubTestServer.scala} | 69 +++++------ .../AwsLambdaTestServerInterpreter.scala | 35 ------ .../aws/lambda/AwsServerInterpreter.scala | 9 +- .../aws/lambda/AwsServerRequest.scala | 4 +- 18 files changed, 275 insertions(+), 228 deletions(-) create mode 100644 server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala rename serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/{LambdaStubTestServer.scala => AwsLambdaStubTestServer.scala} (69%) delete mode 100644 serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala diff --git a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala index 84730e353c..0affa0c410 100644 --- a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala +++ b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala @@ -9,21 +9,15 @@ import cats.implicits._ import org.scalatest.EitherValues import org.scalatest.matchers.should.Matchers._ import sttp.capabilities.akka.AkkaStreams +import akka.http.scaladsl.server.Route +import sttp.capabilities.{WebSockets, akka} import sttp.client3._ import sttp.client3.akkahttp.AkkaHttpBackend import sttp.model.sse.ServerSentEvent import sttp.monad.FutureMonad import sttp.monad.syntax._ import sttp.tapir._ -import sttp.tapir.server.tests.{ - CreateTestServer, - ServerAuthenticationTests, - ServerBasicTests, - ServerMetricsTest, - ServerStreamingTests, - ServerWebSocketTests, - backendResource -} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import java.util.UUID @@ -43,7 +37,7 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { implicit val m: FutureMonad = new FutureMonad()(actorSystem.dispatcher) val interpreter = new AkkaHttpTestServerInterpreter()(actorSystem) - val createTestServer = new CreateTestServer(backend, interpreter) + val createTestServer = new CreateTestServer(backend, interpreter).asInstanceOf[CreateTestServer[Future, AkkaStreams with WebSockets, Route, AkkaResponseBody]] def additionalTests(): List[Test] = List( Test("endpoint nested in a path directive") { @@ -87,10 +81,11 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { ) new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerStreamingTests(createTestServer, AkkaStreams).tests() ++ + new ServerFileMutltipartTests(createTestServer).tests() ++ new ServerWebSocketTests(createTestServer, AkkaStreams) { override def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] = Flow.fromFunction(f) }.tests() ++ + new ServerStreamingTests(createTestServer, AkkaStreams).tests() ++ new ServerAuthenticationTests(createTestServer).tests() ++ new ServerMetricsTest(createTestServer).tests() ++ additionalTests() diff --git a/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala b/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala index 7736cf43f7..86117033c7 100644 --- a/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala +++ b/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.finatra.cats import cats.effect.{IO, Resource} import sttp.client3.impl.cats.CatsMonadAsyncError -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} class FinatraServerCatsTests extends TestSuite { @@ -10,9 +10,10 @@ class FinatraServerCatsTests extends TestSuite { implicit val m: CatsMonadAsyncError[IO] = new CatsMonadAsyncError[IO]() val interpreter = new FinatraCatsTestServerInterpreter() - val createServerTest = new CreateTestServer(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) - new ServerBasicTests(backend, createServerTest, interpreter).tests() ++ - new ServerAuthenticationTests(backend, createServerTest).tests() + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests(createTestServer).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() } } diff --git a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala index 2ae601f1fe..f6e9f2faf2 100644 --- a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala +++ b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala @@ -3,7 +3,14 @@ package sttp.tapir.server.finatra import cats.effect.{IO, Resource} import sttp.monad.MonadError import sttp.tapir.server.finatra.FinatraServerInterpreter.FutureMonadError -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} +import sttp.tapir.server.tests.{ + CreateTestServer, + ServerAuthenticationTests, + ServerBasicTests, + ServerFileMutltipartTests, + ServerMetricsTest, + backendResource +} import sttp.tapir.tests.{Test, TestSuite} class FinatraServerTest extends TestSuite { @@ -15,6 +22,7 @@ class FinatraServerTest extends TestSuite { implicit val m: MonadError[com.twitter.util.Future] = FutureMonadError new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests(createTestServer).tests() ++ new ServerAuthenticationTests(createTestServer).tests() ++ new ServerMetricsTest(createTestServer).tests() } diff --git a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala index 1af6ecd11e..02f942ef23 100644 --- a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala +++ b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala @@ -14,7 +14,7 @@ import sttp.client3._ import sttp.model.sse.ServerSentEvent import sttp.tapir._ import sttp.tapir.integ.cats.CatsMonadError -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import sttp.ws.{WebSocket, WebSocketFrame} @@ -105,6 +105,7 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi ) new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests(createTestServer).tests() ++ new ServerStreamingTests(createTestServer, Fs2Streams[IO]).tests() ++ new ServerWebSocketTests(createTestServer, Fs2Streams[IO]) { override def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] = in => in.map(f) diff --git a/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala b/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala index eeed4882e8..012738c1bb 100644 --- a/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala +++ b/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala @@ -3,7 +3,14 @@ package sttp.tapir.server.play import akka.actor.ActorSystem import cats.effect.{IO, Resource} import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} +import sttp.tapir.server.tests.{ + CreateTestServer, + ServerAuthenticationTests, + ServerBasicTests, + ServerFileMutltipartTests, + ServerMetricsTest, + backendResource +} import sttp.tapir.tests.{Test, TestSuite} class PlayServerTest extends TestSuite { @@ -22,10 +29,10 @@ class PlayServerTest extends TestSuite { createTestServer, interpreter, multipleValueHeaderSupport = false, - multipartInlineHeaderSupport = false, inputStreamSupport = false ).tests() ++ - new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerFileMutltipartTests(createTestServer, multipartInlineHeaderSupport = false).tests() + new ServerAuthenticationTests(createTestServer).tests() ++ new ServerMetricsTest(createTestServer).tests() } } diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala index d3012c1e25..7828e84a9b 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala @@ -1,6 +1,5 @@ package sttp.tapir.server.tests -import cats.effect.IO import cats.implicits._ import org.scalatest.matchers.should.Matchers import sttp.client3._ diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala index d346bb5731..3e32ae707d 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala @@ -34,7 +34,6 @@ class ServerBasicTests[F[_], ROUTE, B]( createTestServer: TestServer[F, Any, ROUTE, B], serverInterpreter: TestServerInterpreter[F, Any, ROUTE, B], multipleValueHeaderSupport: Boolean = true, - multipartInlineHeaderSupport: Boolean = true, inputStreamSupport: Boolean = true )(implicit m: MonadError[F] @@ -47,9 +46,7 @@ class ServerBasicTests[F[_], ROUTE, B]( private def suspendResult[T](t: => T): F[T] = m.eval(t) def tests(): List[Test] = - basicTests() ++ - (if (multipartInlineHeaderSupport) multipartInlineHeaderTests() else Nil) ++ - (if (inputStreamSupport) inputStreamTests() else Nil) + basicTests() ++ (if (inputStreamSupport) inputStreamTests() else Nil) def basicTests(): List[Test] = List( testServer(in_string_out_status_from_type_erasure_using_partial_matcher)((v: String) => @@ -192,13 +189,6 @@ class ServerBasicTests[F[_], ROUTE, B]( testServer(in_unit_error_out_string, "default error status mapper")((_: Unit) => pureResult("".asLeft[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri/api").send(backend).map(_.code shouldBe StatusCode.BadRequest) }, - testServer(in_file_out_file)((file: File) => pureResult(file.asRight[Unit])) { (backend, baseUri) => - basicRequest - .post(uri"$baseUri/api/echo") - .body("pen pineapple apple pen") - .send(backend) - .map(_.body shouldBe Right("pen pineapple apple pen")) - }, testServer(in_form_out_form)((fa: FruitAmount) => pureResult(fa.copy(fruit = fa.fruit.reverse, amount = fa.amount + 1).asRight[Unit])) { (backend, baseUri) => basicRequest @@ -238,57 +228,6 @@ class ServerBasicTests[F[_], ROUTE, B]( testServer(in_paths_out_string, "paths should match empty path")((ps: Seq[String]) => pureResult(ps.mkString(" ").asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri").send(backend).map(_.body shouldBe Right("")) }, - testServer(in_simple_multipart_out_multipart)((fa: FruitAmount) => - pureResult(FruitAmount(fa.fruit + " apple", fa.amount * 2).asRight[Unit]) - ) { (backend, baseUri) => - basicStringRequest - .post(uri"$baseUri/api/echo/multipart") - .multipartBody(multipart("fruit", "pineapple"), multipart("amount", "120")) - .send(backend) - .map { r => - r.body should include regex "name=\"fruit\"[\\s\\S]*pineapple apple" - r.body should include regex "name=\"amount\"[\\s\\S]*240" - } - }, - testServer(in_file_multipart_out_multipart)((fd: FruitData) => - pureResult( - FruitData( - Part("", writeToFile(Await.result(readFromFile(fd.data.body), 3.seconds).reverse), fd.data.otherDispositionParams, Nil) - .header("X-Auth", fd.data.headers.find(_.is("X-Auth")).map(_.value).toString) - ).asRight[Unit] - ) - ) { (backend, baseUri) => - val file = writeToFile("peach mario") - basicStringRequest - .post(uri"$baseUri/api/echo/multipart") - .multipartBody(multipartFile("data", file).fileName("fruit-data.txt").header("X-Auth", "12Aa")) - .send(backend) - .map { r => - r.code shouldBe StatusCode.Ok - if (multipartInlineHeaderSupport) r.body should include regex "X-Auth: Some\\(12Aa\\)" - r.body should include regex "name=\"data\"[\\s\\S]*oiram hcaep" - } - }, - testServer(in_raw_multipart_out_string)((parts: Seq[Part[Array[Byte]]]) => - pureResult( - parts.map(part => s"${part.name}:${new String(part.body)}").mkString("\n").asRight[Unit] - ) - ) { (backend, baseUri) => - val file1 = writeToFile("peach mario") - val file2 = writeToFile("daisy luigi") - basicStringRequest - .post(uri"$baseUri/api/echo/multipart") - .multipartBody( - multipartFile("file1", file1).fileName("file1.txt"), - multipartFile("file2", file2).fileName("file2.txt") - ) - .send(backend) - .map { r => - r.code shouldBe StatusCode.Ok - r.body should include("file1:peach mario") - r.body should include("file2:daisy luigi") - } - }, testServer(in_query_out_string, "invalid query parameter")((fruit: String) => pureResult(s"fruit: $fruit".asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit2=orange").send(backend).map(_.code shouldBe StatusCode.BadRequest) @@ -809,26 +748,6 @@ class ServerBasicTests[F[_], ROUTE, B]( } ) - def multipartInlineHeaderTests(): List[Test] = List( - testServer(in_file_multipart_out_multipart, "with part content type header")((fd: FruitData) => - pureResult( - FruitData( - Part("", fd.data.body, fd.data.otherDispositionParams, fd.data.headers) - ).asRight[Unit] - ) - ) { (backend, baseUri) => - val file = writeToFile("peach mario") - basicStringRequest - .post(uri"$baseUri/api/echo/multipart") - .multipartBody(multipartFile("data", file).contentType("text/html")) - .send(backend) - .map { r => - r.code shouldBe StatusCode.Ok - r.body.toLowerCase() should include("content-type: text/html") - } - } - ) - def inputStreamTests(): List[Test] = List( testServer(in_input_stream_out_input_stream)((is: InputStream) => pureResult((new ByteArrayInputStream(inputStreamToByteArray(is)): InputStream).asRight[Unit]) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala new file mode 100644 index 0000000000..1adaa4fa8c --- /dev/null +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala @@ -0,0 +1,117 @@ +package sttp.tapir.server.tests + +import cats.implicits._ +import org.scalatest.matchers.should.Matchers._ +import sttp.client3.{basicRequest, multipartFile, _} +import sttp.model.{Part, StatusCode} +import sttp.monad.MonadError +import sttp.tapir.tests.TestUtil.{readFromFile, writeToFile} +import sttp.tapir.tests.{ + FruitAmount, + FruitData, + Test, + in_file_multipart_out_multipart, + in_file_out_file, + in_raw_multipart_out_string, + in_simple_multipart_out_multipart +} + +import java.io.File +import scala.concurrent.Await +import scala.concurrent.duration.DurationInt + +class ServerFileMutltipartTests[F[_], ROUTE, B]( + createTestServer: TestServer[F, Any, ROUTE, B], + multipartInlineHeaderSupport: Boolean = true +)(implicit m: MonadError[F]) { + import createTestServer._ + + private val basicStringRequest = basicRequest.response(asStringAlways) + private def pureResult[T](t: T): F[T] = m.unit(t) + + def tests(): List[Test] = + basicTests() ++ (if (multipartInlineHeaderSupport) multipartInlineHeaderTests() else Nil) + + def basicTests(): List[Test] = { + List( + testServer(in_file_out_file)((file: File) => pureResult(file.asRight[Unit])) { (backend, baseUri) => + basicRequest + .post(uri"$baseUri/api/echo") + .body("pen pineapple apple pen") + .send(backend) + .map(_.body shouldBe Right("pen pineapple apple pen")) + }, + testServer(in_simple_multipart_out_multipart)((fa: FruitAmount) => + pureResult(FruitAmount(fa.fruit + " apple", fa.amount * 2).asRight[Unit]) + ) { (backend, baseUri) => + basicStringRequest + .post(uri"$baseUri/api/echo/multipart") + .multipartBody(multipart("fruit", "pineapple"), multipart("amount", "120")) + .send(backend) + .map { r => + r.body should include regex "name=\"fruit\"[\\s\\S]*pineapple apple" + r.body should include regex "name=\"amount\"[\\s\\S]*240" + } + }, + testServer(in_file_multipart_out_multipart)((fd: FruitData) => + pureResult( + FruitData( + Part("", writeToFile(Await.result(readFromFile(fd.data.body), 3.seconds).reverse), fd.data.otherDispositionParams, Nil) + .header("X-Auth", fd.data.headers.find(_.is("X-Auth")).map(_.value).toString) + ).asRight[Unit] + ) + ) { (backend, baseUri) => + val file = writeToFile("peach mario") + basicStringRequest + .post(uri"$baseUri/api/echo/multipart") + .multipartBody(multipartFile("data", file).fileName("fruit-data.txt").header("X-Auth", "12Aa")) + .send(backend) + .map { r => + r.code shouldBe StatusCode.Ok + if (multipartInlineHeaderSupport) r.body should include regex "X-Auth: Some\\(12Aa\\)" + r.body should include regex "name=\"data\"[\\s\\S]*oiram hcaep" + } + }, + testServer(in_raw_multipart_out_string)((parts: Seq[Part[Array[Byte]]]) => + pureResult( + parts.map(part => s"${part.name}:${new String(part.body)}").mkString("\n").asRight[Unit] + ) + ) { (backend, baseUri) => + val file1 = writeToFile("peach mario") + val file2 = writeToFile("daisy luigi") + basicStringRequest + .post(uri"$baseUri/api/echo/multipart") + .multipartBody( + multipartFile("file1", file1).fileName("file1.txt"), + multipartFile("file2", file2).fileName("file2.txt") + ) + .send(backend) + .map { r => + r.code shouldBe StatusCode.Ok + r.body should include("file1:peach mario") + r.body should include("file2:daisy luigi") + } + } + ) + } + + def multipartInlineHeaderTests(): List[Test] = List( + testServer(in_file_multipart_out_multipart, "with part content type header")((fd: FruitData) => + pureResult( + FruitData( + Part("", fd.data.body, fd.data.otherDispositionParams, fd.data.headers) + ).asRight[Unit] + ) + ) { (backend, baseUri) => + val file = writeToFile("peach mario") + basicStringRequest + .post(uri"$baseUri/api/echo/multipart") + .multipartBody(multipartFile("data", file).contentType("text/html")) + .send(backend) + .map { r => + r.code shouldBe StatusCode.Ok + r.body.toLowerCase() should include("content-type: text/html") + } + } + ) +} diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala index 740cde89e5..0e70e661be 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala @@ -16,7 +16,6 @@ import sttp.tapir.tests.{Fruit, Test} import sttp.ws.{WebSocket, WebSocketFrame} abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( - // backend: SttpBackend[IO, Fs2Streams[IO] with WebSockets], createTestServer: TestServer[F, S with WebSockets, ROUTE, B], val streams: S )(implicit diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala index 6ba571d283..7e5e00fc26 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala @@ -4,7 +4,7 @@ import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.capabilities.fs2.Fs2Streams import sttp.monad.MonadError -import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, ServerStreamingTests, CreateTestServer, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, ServerStreamingTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} class CatsVertxServerTest extends TestSuite { @@ -17,16 +17,15 @@ class CatsVertxServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: MonadError[IO] = VertxCatsServerInterpreter.monadError[IO] val interpreter = new CatsVertxTestServerInterpreter(vertx) - val createServerTest = new CreateTestServer(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) - new ServerBasicTests( - backend, - createServerTest, - interpreter, - multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong - ).tests() ++ - new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerStreamingTests(backend, createServerTest, Fs2Streams.apply[IO]).tests() + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests( + createTestServer, + multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong + ).tests() + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerStreamingTests(createTestServer, Fs2Streams.apply[IO]).tests() } } } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala index 8875d785d1..6e8f9a7640 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.vertx import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, CreateTestServer, backendResource} +import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import scala.concurrent.ExecutionContext @@ -16,14 +16,14 @@ class VertxBlockingServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: FutureMonad = new FutureMonad()(ExecutionContext.global) val interpreter = new VertxTestServerBlockingInterpreter(vertx) - val createServerTest = new CreateTestServer(interpreter) + val createTestServer = new CreateTestServer(backend, interpreter) - new ServerBasicTests( - backend, - createServerTest, - interpreter, - multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong - ).tests() ++ new ServerAuthenticationTests(backend, createServerTest).tests() + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests( + createTestServer, + multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong + ).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() } } } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala index 5a089e6de5..2051e7c9bb 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala @@ -3,7 +3,14 @@ package sttp.tapir.server.vertx import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerMetricsTest, backendResource} +import sttp.tapir.server.tests.{ + CreateTestServer, + ServerAuthenticationTests, + ServerBasicTests, + ServerFileMutltipartTests, + ServerMetricsTest, + backendResource +} import sttp.tapir.tests.{Test, TestSuite} import scala.concurrent.ExecutionContext @@ -19,11 +26,11 @@ class VertxServerTest extends TestSuite { val interpreter = new VertxTestServerInterpreter(vertx) val createTestServer = new CreateTestServer(backend, interpreter) - new ServerBasicTests( - createTestServer, - interpreter, - multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong - ).tests() ++ + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests( + createTestServer, + multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong + ).tests() ++ new ServerAuthenticationTests(createTestServer).tests() ++ new ServerMetricsTest(createTestServer).tests() } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala index dc2f462f52..3ddfdac566 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala @@ -2,12 +2,20 @@ package sttp.tapir.server.vertx import cats.effect.{IO, Resource} import io.vertx.core.Vertx +import io.vertx.ext.web.{Route, Router, RoutingContext} import sttp.capabilities.zio.ZioStreams import sttp.monad.MonadError -import sttp.tapir.server.tests.{ServerAuthenticationTests, ServerBasicTests, ServerStreamingTests, CreateTestServer, backendResource} +import sttp.tapir.server.tests.{ + CreateTestServer, + ServerAuthenticationTests, + ServerBasicTests, + ServerFileMutltipartTests, + ServerStreamingTests, + backendResource +} import sttp.tapir.tests.{Test, TestSuite} -import zio.interop.catz._ import zio.Task +import zio.interop.catz._ class ZioVertxServerTest extends TestSuite { import VertxZioServerInterpreter._ @@ -20,16 +28,16 @@ class ZioVertxServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: MonadError[Task] = VertxZioServerInterpreter.monadError val interpreter = new ZioVertxTestServerInterpreter(vertx) - val createServerTest = new CreateTestServer(interpreter) + val createTestServer = + new CreateTestServer(backend, interpreter).asInstanceOf[CreateTestServer[Task, ZioStreams, Router => Route, RoutingContext => Unit]] - new ServerBasicTests( - backend, - createServerTest, - interpreter, - multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong - ).tests() ++ - new ServerAuthenticationTests(backend, createServerTest).tests() ++ - new ServerStreamingTests(backend, createServerTest, ZioStreams).tests() + new ServerBasicTests(createTestServer, interpreter).tests() ++ + new ServerFileMutltipartTests( + createTestServer, + multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong + ).tests() ++ + new ServerAuthenticationTests(createTestServer).tests() ++ + new ServerStreamingTests(createTestServer, ZioStreams).tests() } } } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala index 128e494bf8..080a2acd7c 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala @@ -1,17 +1,49 @@ package sttp.tapir.serverless.aws.lambda.tests +import cats.data.NonEmptyList import cats.effect.{IO, Resource} -import sttp.tapir.server.tests.ServerBasicTests -import sttp.tapir.serverless.aws.lambda.Route -import sttp.tapir.serverless.aws.lambda.tests.LambdaStubTestServer._ -import sttp.tapir.tests.{Test, TestSuite} +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor +import sttp.tapir.server.tests.{ServerBasicTests, ServerMetricsTest, TestServerInterpreter} +import sttp.tapir.serverless.aws.lambda.tests.AwsLambdaStubTestServer._ +import sttp.tapir.serverless.aws.lambda.{AwsServerInterpreter, AwsServerOptions, Route} +import sttp.tapir.tests.{Port, Test, TestSuite} + +import scala.reflect.ClassTag class AwsLambdaStubHttpTest extends TestSuite { - override def tests: Resource[IO, List[Test]] = Resource.eval { + override def tests: Resource[IO, List[Test]] = Resource.eval( IO.pure { - val interpreter = new AwsLambdaTestServerInterpreter - val createTestServer = new LambdaStubTestServer - new ServerBasicTests[IO, Route[IO], String](createTestServer, interpreter)(catsMonadIO).tests() + val createTestServer = new AwsLambdaStubTestServer + new ServerBasicTests(createTestServer, AwsLambdaStubHttpTest.testServerInterpreter)(catsMonadIO).tests() ++ + new ServerMetricsTest(createTestServer).tests() + } + ) + +// override def testNameFilter: Option[String] = Some("Endpoint(in: GET /api /echo /param-to-header ?qq, errout: -, out: {header hh})") +} + +object AwsLambdaStubHttpTest { + private val testServerInterpreter = new TestServerInterpreter[IO, Any, Route[IO], String] { + override def route[I, E, O]( + e: ServerEndpoint[I, E, O, Any, IO], + decodeFailureHandler: Option[DecodeFailureHandler], + metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] + ): Route[IO] = { + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( + metricsInterceptor = metricsInterceptor, + decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) + ) + AwsServerInterpreter.toRoute(e) + } + override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit + eClassTag: ClassTag[E] + ): Route[IO] = { + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + AwsServerInterpreter.toRouteRecoverErrors(e)(fn) } + override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = ??? } } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala similarity index 69% rename from serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala rename to serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala index fc3183460e..7e9a831389 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaStubTestServer.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala @@ -2,7 +2,7 @@ package sttp.tapir.serverless.aws.lambda.tests import cats.data.NonEmptyList import cats.effect.IO -import org.scalatest.{Assertion, Assertions} +import org.scalatest.Assertion import sttp.client3 import sttp.client3.testing.SttpBackendStub import sttp.client3.{ByteArrayBody, ByteBufferBody, InputStreamBody, NoBody, Request, Response, StringBody, SttpBackend, _} @@ -15,13 +15,12 @@ import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import sttp.tapir.server.stub._ import sttp.tapir.server.tests.TestServer import sttp.tapir.serverless.aws.lambda._ -import sttp.tapir.serverless.aws.lambda.tests.LambdaStubTestServer._ +import sttp.tapir.serverless.aws.lambda.tests.AwsLambdaStubTestServer._ import sttp.tapir.tests.Test import java.util.Base64 -import scala.util.Random -class LambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { +class AwsLambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { override def testServer[I, E, O]( e: Endpoint[I, E, O, Any], @@ -36,13 +35,8 @@ class LambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { val se: ServerEndpoint[I, E, O, Any, IO] = e.serverLogic(fn) val route: Route[IO] = AwsServerInterpreter.toRoute(se) val backend: SttpBackendStub[IO, Any] = - SttpBackendStub(catsMonadIO) - .whenRequestMatchesEndpointThenInterpret( - e, - request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) - ) - .whenAnyRequest - .thenRespondNotFound() + SttpBackendStub(catsMonadIO).whenAnyRequest + .thenRespondF { request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) } val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) @@ -55,39 +49,40 @@ class LambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() val route: Route[IO] = AwsServerInterpreter.toRoute(e) val backend = - SttpBackendStub(catsMonadIO).whenRequestMatchesEndpointThenInterpret( - e.endpoint, - request => { - val awsReq = sttpToAwsRequest(request) - route(awsReq).map { awsRes => - println(awsReq) - println(awsRes) - val sttpRes = awsToSttpResponse(awsRes) - println(sttpRes) - sttpRes - } -// route(sttpToAwsRequest(request)).map(awsToSttpResponse) - } - ) + SttpBackendStub(catsMonadIO) + .whenRequestMatchesEndpointThenInterpret( + e.endpoint, + request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) + ) + .whenAnyRequest + .thenRespondNotFound() val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) - Test(name)(() => runTest(backend, uri"http://localhost:3000").unsafeRunSync()) + Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) } override def testServer(name: String, rs: => NonEmptyList[Route[IO]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { - Test("fail " + Random.nextInt())(() => Assertions.fail()) + val backend: SttpBackendStub[IO, Any] = SttpBackendStub(catsMonadIO).whenAnyRequest + .thenRespondF { request => + val responses: NonEmptyList[Response[String]] = rs.map { route => + route(sttpToAwsRequest(request)).map(awsToSttpResponse).unsafeRunSync() + } + IO.pure(responses.find(_.code != StatusCode.NotFound).getOrElse(Response("", StatusCode.NotFound))) + } + Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) } } -object LambdaStubTestServer { +object AwsLambdaStubTestServer { implicit val catsMonadIO: CatsMonadError[IO] = new CatsMonadError[IO] def sttpToAwsRequest(request: Request[_, _]): AwsRequest = { - println("CALLING: " + request.uri.toJavaUri.toString) AwsRequest( rawPath = request.uri.pathSegments.toString, - rawQueryString = request.uri.params.toMultiSeq.foldLeft("") { case (q, (name, values)) => s"$q$name=${values.mkString(",")}" }, + rawQueryString = request.uri.params.toMultiSeq.foldLeft("") { case (q, (name, values)) => + s"${if (q == "") "" else s"$q&"}${if (values.isEmpty) name else values.map(v => s"$name=$v").mkString("&")}" + }, headers = request.headers.map(h => h.name -> h.value).toMap, requestContext = AwsRequestContext( domainName = Some("localhost:3000"), @@ -100,12 +95,12 @@ object LambdaStubTestServer { ) ), Some(request.body match { - case NoBody => "" - case StringBody(b, encoding, _) => new String(b) - case ByteArrayBody(b, _) => new String(b) - case ByteBufferBody(b, _) => new String(b.array()) - case InputStreamBody(b, _) => new String(b.readAllBytes()) - case _ => throw new UnsupportedOperationException + case NoBody => "" + case StringBody(b, _, _) => new String(b) + case ByteArrayBody(b, _) => new String(b) + case ByteBufferBody(b, _) => new String(b.array()) + case InputStreamBody(b, _) => new String(b.readAllBytes()) + case _ => throw new UnsupportedOperationException }), isBase64Encoded = false ) @@ -116,6 +111,6 @@ object LambdaStubTestServer { new String(Base64.getDecoder.decode(response.body)), new StatusCode(response.statusCode), "", - response.headers.map { case (n, v) => Header(n, v) }.toSeq + response.headers.map { case (n, v) => v.split(",").map(Header(n, _)) }.flatten.toSeq ) } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala deleted file mode 100644 index 5d864ac3d9..0000000000 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaTestServerInterpreter.scala +++ /dev/null @@ -1,35 +0,0 @@ -package sttp.tapir.serverless.aws.lambda.tests - -import cats.data.NonEmptyList -import cats.effect.{IO, Resource} -import sttp.tapir.Endpoint -import sttp.tapir.server.ServerEndpoint -import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} -import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor -import sttp.tapir.server.tests.TestServerInterpreter -import sttp.tapir.serverless.aws.lambda.{AwsServerInterpreter, AwsServerOptions, Route} -import sttp.tapir.tests.Port - -import scala.reflect.ClassTag - -class AwsLambdaTestServerInterpreter extends TestServerInterpreter[IO, Any, Route[IO], String] { - - override def route[I, E, O]( - e: ServerEndpoint[I, E, O, Any, IO], - decodeFailureHandler: Option[DecodeFailureHandler], - metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] - ): Route[IO] = { - implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( - metricsInterceptor = metricsInterceptor, - decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) - ) - AwsServerInterpreter.toRoute(e) - } - - override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit eClassTag: ClassTag[E]): Route[IO] = { - implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() - AwsServerInterpreter.toRouteRecoverErrors(e)(fn) - } - - override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = ??? -} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index 0861ca1e1a..bafbb142e9 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -2,7 +2,7 @@ package sttp.tapir.serverless.aws.lambda import cats.data.Kleisli import cats.effect.Sync -import sttp.model.StatusCode +import sttp.model.{Header, StatusCode} import sttp.monad.syntax._ import sttp.tapir.Endpoint import sttp.tapir.integ.cats.CatsMonadError @@ -42,12 +42,9 @@ trait AwsServerInterpreter { interpreter.apply(serverRequest, ses).map { case None => AwsResponse(Nil, isBase64Encoded = true, StatusCode.NotFound.code, Map.empty, "") case Some(res) => - println(res) val cookies = res.cookies.collect { case Right(cookie) => cookie.value }.toList - val headers = res.headers.map(h => h.name -> h.value).toMap - val awsRes = AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) - println(awsRes) - awsRes + val headers = res.headers.groupMapReduce(_.name)(_.value)((v1, v2) => s"$v1,$v2") + AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) } } } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala index 46f44b19ea..601e5dddbd 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala @@ -8,9 +8,7 @@ import java.net.{InetSocketAddress, URLDecoder} private[lambda] class AwsServerRequest(request: AwsRequest) extends ServerRequest { private val sttpUri: Uri = { val queryString = if (request.rawQueryString.nonEmpty) "?" + request.rawQueryString else "" - val uri = Uri.unsafeParse(s"$protocol://${request.requestContext.domainName.getOrElse("")}${request.rawPath}$queryString") - println("GOT " + uri.toJavaUri.toString) - uri + Uri.unsafeParse(s"$protocol://${request.requestContext.domainName.getOrElse("")}${request.rawPath}$queryString") } override def protocol: String = request.headers.getOrElse("x-forwarded-proto", "http") From ad8c83faf889d599d2b03994884df8a10ce023e0 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 13 May 2021 18:26:28 +0200 Subject: [PATCH 07/35] invoke sam local in sbt test --- build.sbt | 31 +++++-- .../aws/lambda/tests/LambdaHandler.scala | 2 +- .../aws/lambda/tests/LambdaSamTemplate.scala | 9 +- .../serverless/aws/lambda/tests/package.scala | 3 + ....scala => AwsLambdaSamLocalHttpTest.scala} | 2 +- .../lambda/tests/AwsLambdaStubHttpTest.scala | 2 - .../aws/lambda/AwsServerOptions.scala | 7 +- .../aws/sam/AwsSamInterpreter.scala | 82 +++---------------- .../aws/sam/EndpointsToSamTemplate.scala | 76 +++++++++++++++++ .../sttp/tapir/serverless/aws/sam/model.scala | 10 ++- .../test/resources/code_source_template.yaml | 37 +++++++++ .../test/resources/image_source_template.yaml | 36 ++++++++ .../aws/sam/VerifySamTemplateTest.scala | 66 +++++++++++++++ 13 files changed, 271 insertions(+), 92 deletions(-) rename serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/{AwsLambdaHttpTest.scala => AwsLambdaSamLocalHttpTest.scala} (97%) create mode 100644 serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala create mode 100644 serverless/aws/sam/src/test/resources/code_source_template.yaml create mode 100644 serverless/aws/sam/src/test/resources/image_source_template.yaml create mode 100644 serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala diff --git a/build.sbt b/build.sbt index ff23a6a950..cd43cdeb30 100644 --- a/build.sbt +++ b/build.sbt @@ -4,6 +4,8 @@ import sbt.Reference.display import sbt.internal.ProjectMatrix import java.net.URL +import scala.concurrent.duration.DurationInt +import scala.sys.process.Process val scala2_12 = "2.12.13" val scala2_13 = "2.13.5" @@ -17,7 +19,6 @@ scalaVersion := scala2_13 lazy val clientTestServerPort = settingKey[Int]("Port to run the client interpreter test server on") lazy val startClientTestServer = taskKey[Unit]("Start a http server used by client interpreter tests") -lazy val generateSamTemplate = taskKey[Unit]("Generate sam template for lamdba interpreter tests") concurrentRestrictions in Global += Tags.limit(Tags.Test, 1) @@ -102,6 +103,10 @@ lazy val allAggregates = core.projectRefs ++ playServer.projectRefs ++ vertxServer.projectRefs ++ zioServer.projectRefs ++ + awsLambda.projectRefs ++ + awsLambdaTests.projectRefs ++ + awsSam.projectRefs ++ + awsExamples.projectRefs ++ http4sClient.projectRefs ++ sttpClient.projectRefs ++ playClient.projectRefs ++ @@ -872,6 +877,9 @@ lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambd .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, cats, circeJson, awsSam) +lazy val sam = Process("sam local start-api --warm-containers EAGER").run() +lazy val samTemplate = taskKey[Unit]("Generate sam template for lambda tests") + lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda-tests")) .settings(commonJvmSettings) .settings( @@ -883,12 +891,19 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first case x => (assembly / assemblyMergeStrategy).value(x) }, -// test := { -// generateSamTemplate.value -// assembly.value -// }, - Test / parallelExecution := false, - generateSamTemplate := (Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate").value + samTemplate := (Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate").value, + Test / test := (Test / test) + .dependsOn(samTemplate) + .dependsOn(assembly) + .value, + Test / testOptions += Tests.Setup(() => { + val ok = PollingUtils.poll(10.seconds, 1.second) { + sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) + } + if (!ok) sam.destroy() + }), + Test / testOptions += Tests.Cleanup(() => sam.destroy()), + Test / parallelExecution := false ) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, cats, circeJson, awsLambda, awsSam, sttpStubServer, tests, serverTests) @@ -903,7 +918,7 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) ) ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core) + .dependsOn(core, tests % Test) lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) .enablePlugins(JavaAppPackaging, DockerPlugin) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 2bfaac41f4..8651ffdb89 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -15,7 +15,7 @@ import java.nio.charset.StandardCharsets object LambdaHandler extends RequestStreamHandler { override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() allEndpoints.foreach(e => println(e.endpoint.showDetail)) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala index a11811e589..3051d564f5 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala @@ -1,7 +1,5 @@ package sttp.tapir.serverless.aws.lambda.tests -import io.circe.syntax._ -import sttp.tapir.serverless.aws.sam.AwsSamTemplateEncoders._ import sttp.tapir.serverless.aws.sam._ import java.nio.charset.StandardCharsets @@ -12,13 +10,12 @@ object LambdaSamTemplate extends App { "Tests", source = CodeSource( "java11", - "target/jvm-2.13/tapir-aws-lambda-tests.jar", + "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/lambda-tests/target/jvm-2.13/tapir-aws-lambda-tests.jar", "sttp.tapir.serverless.aws.lambda.tests.LambdaHandler::handleRequest" ), memorySize = 1024 ) - val samTemplate = new AwsSamInterpreter().apply(allEndpoints.map(_.endpoint).toList) - val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain).pretty(samTemplate.asJson) - val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/lambda-tests/template.yaml" + val yaml = AwsSamInterpreter.toSamTemplate(allEndpoints.map(_.endpoint).toList).toYaml + val targetFile = "/Users/kubinio/Desktop/workspace/tapir/template.yaml" Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) } diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala index cd2de7e53b..514755093d 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala @@ -10,6 +10,9 @@ import sttp.tapir.tests._ package object tests { + // this endpoint is used to wait until sam local starts up before running actual tests + val health_endpoint: ServerEndpoint[Unit, Unit, Unit, Any, IO] = endpoint.get.in("health").serverLogic(_ => IO.pure(().asRight[Unit])) + val in_path_path_out_string_endpoint: ServerEndpoint[(String, Port), Unit, String, Any, IO] = in_path_path_out_string.serverLogic { case (fruit: String, amount: Int) => IO.pure(s"$fruit $amount".asRight[Unit]) } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala similarity index 97% rename from serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala rename to serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala index 369a5351c4..e40779d5ac 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala @@ -14,7 +14,7 @@ import sttp.tapir.server.tests.backendResource import java.util.Base64 -class AwsLambdaHttpTest extends AnyFunSuite { +class AwsLambdaSamLocalHttpTest extends AnyFunSuite { private val baseUri: Uri = uri"http://localhost:3000" diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala index 080a2acd7c..63ae9427fc 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala @@ -21,8 +21,6 @@ class AwsLambdaStubHttpTest extends TestSuite { new ServerMetricsTest(createTestServer).tests() } ) - -// override def testNameFilter: Option[String] = Some("Endpoint(in: GET /api /echo /param-to-header ?qq, errout: -, out: {header hh})") } object AwsLambdaStubHttpTest { diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala index de40b57c40..5569156855 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala @@ -4,6 +4,7 @@ import sttp.tapir.server.interceptor.Interceptor import sttp.tapir.server.interceptor.content.UnsupportedMediaTypeInterceptor import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DecodeFailureInterceptor, DefaultDecodeFailureHandler} import sttp.tapir.server.interceptor.exception.{DefaultExceptionHandler, ExceptionHandler, ExceptionInterceptor} +import sttp.tapir.server.interceptor.log.ServerLogInterceptor import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor case class AwsServerOptions[F[_]](interceptors: List[Interceptor[F, String]]) { @@ -12,10 +13,10 @@ case class AwsServerOptions[F[_]](interceptors: List[Interceptor[F, String]]) { } object AwsServerOptions { - def customInterceptors[F[_]]( + def customInterceptors[F[_], T]( metricsInterceptor: Option[MetricsRequestInterceptor[F, String]] = None, exceptionHandler: Option[ExceptionHandler] = Some(DefaultExceptionHandler), - // todo log serverLog: Option[ServerLog[Context => F[Unit]]] = None, + serverLogInterceptor: Option[ServerLogInterceptor[T, F, String]] = None, additionalInterceptors: List[Interceptor[F, String]] = Nil, unsupportedMediaTypeInterceptor: Option[UnsupportedMediaTypeInterceptor[F, String]] = Some( new UnsupportedMediaTypeInterceptor[F, String]() @@ -24,7 +25,7 @@ object AwsServerOptions { ): AwsServerOptions[F] = AwsServerOptions( metricsInterceptor.toList ++ exceptionHandler.map(new ExceptionInterceptor[F, String](_)).toList ++ - // todo log + serverLogInterceptor.toList ++ additionalInterceptors ++ unsupportedMediaTypeInterceptor.toList ++ List(new DecodeFailureInterceptor[F, String](decodeFailureHandler)) diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala index 70492334d4..3a0bb467a1 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamInterpreter.scala @@ -1,78 +1,20 @@ package sttp.tapir.serverless.aws.sam -import sttp.model.Method -import sttp.tapir.internal._ -import sttp.tapir.{Endpoint, EndpointInput} +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint -import scala.collection.immutable.ListMap +trait AwsSamInterpreter { + def toSamTemplate[I, E, O, S](e: Endpoint[I, E, O, S])(implicit options: AwsSamOptions): SamTemplate = EndpointsToSamTemplate(List(e)) -class AwsSamInterpreter { - type AnyEndpoint = Endpoint[_, _, _, _] + def toSamTemplate(es: Iterable[Endpoint[_, _, _, _]])(implicit options: AwsSamOptions): SamTemplate = EndpointsToSamTemplate(es.toList) - def apply(es: List[AnyEndpoint])(implicit options: AwsSamOptions): SamTemplate = { - val functionName = options.namePrefix + "Function" - val httpApiName = options.namePrefix + "HttpApi" - - val apiEvents = es.map(endpointNameMethodAndPath).map { case (name, method, path) => - name -> FunctionHttpApiEvent( - FunctionHttpApiEventProperties(s"!Ref $httpApiName", method.map(_.method).getOrElse("ANY"), path, options.timeout.toMillis) - ) - } - - SamTemplate( - Resources = ListMap( - functionName -> FunctionResource( - options.source match { - case ImageSource(imageUri) => - FunctionImageProperties(options.timeout.toSeconds, options.memorySize, ListMap.from(apiEvents), imageUri) - case cs @ CodeSource(_, _, _) => - FunctionCodeProperties( - options.timeout.toSeconds, - options.memorySize, - ListMap.from(apiEvents), - cs.runtime, - cs.codeUri, - cs.handler - ) - } - ), - httpApiName -> HttpResource(HttpProperties("$default")) - ), - Outputs = ListMap( - (options.namePrefix + "Url") -> Output( - "Base URL of your endpoints", - ListMap("Fn::Sub" -> ("https://${" + httpApiName + "}.execute-api.${AWS::Region}.${AWS::URLSuffix}")) - ) - ) + def toSamTemplate[I, E, O, S, F[_]](se: ServerEndpoint[I, E, O, S, F])(implicit options: AwsSamOptions): SamTemplate = + EndpointsToSamTemplate( + List(se.endpoint) ) - } - - private def endpointNameMethodAndPath(e: AnyEndpoint): (String, Option[Method], String) = { - val pathComponents = e.input - .asVectorOfBasicInputs() - .collect { - case EndpointInput.PathCapture(name, _, _) => Left(name) - case EndpointInput.FixedPath(s, _, _) => Right(s) - } - .foldLeft((Vector.empty[Either[String, String]], 0)) { case ((acc, c), component) => - component match { - case Left(None) => (acc :+ Left(s"param$c"), c + 1) - case Left(Some(p)) => (acc :+ Left(p), c) - case Right(p) => (acc :+ Right(p), c) - } - } - ._1 - - val method = e.httpMethod - val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map(_.fold(identity, identity)) - val name = (method.map(_.method.toLowerCase).getOrElse("any").capitalize +: nameComponents.map(_.toLowerCase.capitalize)).mkString - - val idComponents = pathComponents.map { - case Left(s) => s"{$s}" - case Right(s) => s - } - - (name, method, "/" + idComponents.mkString("/")) - } + def serverEndpointsToSamTemplate[F[_]](ses: Iterable[ServerEndpoint[_, _, _, _, F]])(implicit options: AwsSamOptions): SamTemplate = + EndpointsToSamTemplate(ses.map(_.endpoint).toList) } + +object AwsSamInterpreter extends AwsSamInterpreter \ No newline at end of file diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala new file mode 100644 index 0000000000..01aaca75a5 --- /dev/null +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala @@ -0,0 +1,76 @@ +package sttp.tapir.serverless.aws.sam + +import sttp.model.Method +import sttp.tapir.internal._ +import sttp.tapir.{Endpoint, EndpointInput} + +import scala.collection.immutable.ListMap + +private[sam] object EndpointsToSamTemplate { + def apply(es: List[Endpoint[_, _, _, _]])(implicit options: AwsSamOptions): SamTemplate = { + val functionName = options.namePrefix + "Function" + val httpApiName = options.namePrefix + "HttpApi" + + val apiEvents = es.map(endpointNameMethodAndPath).map { case (name, method, path) => + name -> FunctionHttpApiEvent( + FunctionHttpApiEventProperties(s"!Ref $httpApiName", method.map(_.method).getOrElse("ANY"), path, options.timeout.toMillis) + ) + } + + SamTemplate( + Resources = ListMap( + functionName -> FunctionResource( + options.source match { + case ImageSource(imageUri) => + FunctionImageProperties(options.timeout.toSeconds, options.memorySize, ListMap.from(apiEvents), imageUri) + case cs @ CodeSource(_, _, _) => + FunctionCodeProperties( + options.timeout.toSeconds, + options.memorySize, + ListMap.from(apiEvents), + cs.runtime, + cs.codeUri, + cs.handler + ) + } + ), + httpApiName -> HttpResource(HttpProperties("$default")) + ), + Outputs = ListMap( + (options.namePrefix + "Url") -> Output( + "Base URL of your endpoints", + ListMap("Fn::Sub" -> ("https://${" + httpApiName + "}.execute-api.${AWS::Region}.${AWS::URLSuffix}")) + ) + ) + ) + } + + private def endpointNameMethodAndPath(e: Endpoint[_, _, _, _]): (String, Option[Method], String) = { + val pathComponents = e.input + .asVectorOfBasicInputs() + .collect { + case EndpointInput.PathCapture(name, _, _) => Left(name) + case EndpointInput.FixedPath(s, _, _) => Right(s) + } + .foldLeft((Vector.empty[Either[String, String]], 0)) { case ((acc, c), component) => + component match { + case Left(None) => (acc :+ Left(s"param$c"), c + 1) + case Left(Some(p)) => (acc :+ Left(p), c) + case Right(p) => (acc :+ Right(p), c) + } + } + ._1 + + val method = e.httpMethod + + val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map(_.fold(identity, identity)) + val name = (method.map(_.method.toLowerCase).getOrElse("any").capitalize +: nameComponents.map(_.toLowerCase.capitalize)).mkString + + val idComponents = pathComponents.map { + case Left(s) => s"{$s}" + case Right(s) => s + } + + (name, method, "/" + idComponents.mkString("/")) + } +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala index 4e42dd96f9..db2f17a171 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala @@ -1,5 +1,8 @@ package sttp.tapir.serverless.aws.sam +import io.circe.syntax._ +import sttp.tapir.serverless.aws.sam.AwsSamTemplateEncoders._ + import scala.collection.immutable.ListMap case class SamTemplate( @@ -7,7 +10,12 @@ case class SamTemplate( Transform: String = "AWS::Serverless-2016-10-31", Resources: ListMap[String, Resource], Outputs: ListMap[String, Output] -) +) { + def toYaml: String = { + val template: SamTemplate = this + Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain).pretty(template.asJson) + } +} trait Resource { def Properties: Properties diff --git a/serverless/aws/sam/src/test/resources/code_source_template.yaml b/serverless/aws/sam/src/test/resources/code_source_template.yaml new file mode 100644 index 0000000000..ee3ce3b8e9 --- /dev/null +++ b/serverless/aws/sam/src/test/resources/code_source_template.yaml @@ -0,0 +1,37 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Resources: + PetApiFunction: + Properties: + Timeout: 10 + MemorySize: 1024 + Events: + GetApiPetsId: + Properties: + ApiId: !Ref 'PetApiHttpApi' + Method: GET + Path: /api/pets/{id} + TimeoutInMillis: 10000 + PayloadFormatVersion: '2.0' + Type: HttpApi + PostApiPets: + Properties: + ApiId: !Ref 'PetApiHttpApi' + Method: POST + Path: /api/pets + TimeoutInMillis: 10000 + PayloadFormatVersion: '2.0' + Type: HttpApi + Runtime: java11 + CodeUri: /somewhere/pet-api.jar + Handler: pet.api.Handler::handleRequest + Type: AWS::Serverless::Function + PetApiHttpApi: + Properties: + StageName: $default + Type: AWS::Serverless::HttpApi +Outputs: + PetApiUrl: + Description: Base URL of your endpoints + Value: + Fn::Sub: https://${PetApiHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix} diff --git a/serverless/aws/sam/src/test/resources/image_source_template.yaml b/serverless/aws/sam/src/test/resources/image_source_template.yaml new file mode 100644 index 0000000000..e683ad4bd6 --- /dev/null +++ b/serverless/aws/sam/src/test/resources/image_source_template.yaml @@ -0,0 +1,36 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Resources: + PetApiFunction: + Properties: + Timeout: 10 + MemorySize: 1024 + Events: + GetApiPetsId: + Properties: + ApiId: !Ref 'PetApiHttpApi' + Method: GET + Path: /api/pets/{id} + TimeoutInMillis: 10000 + PayloadFormatVersion: '2.0' + Type: HttpApi + PostApiPets: + Properties: + ApiId: !Ref 'PetApiHttpApi' + Method: POST + Path: /api/pets + TimeoutInMillis: 10000 + PayloadFormatVersion: '2.0' + Type: HttpApi + ImageUri: image.repository:pet-api + PackageType: Image + Type: AWS::Serverless::Function + PetApiHttpApi: + Properties: + StageName: $default + Type: AWS::Serverless::HttpApi +Outputs: + PetApiUrl: + Description: Base URL of your endpoints + Value: + Fn::Sub: https://${PetApiHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix} \ No newline at end of file diff --git a/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala b/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala new file mode 100644 index 0000000000..861f0d0d2e --- /dev/null +++ b/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala @@ -0,0 +1,66 @@ +package sttp.tapir.serverless.aws.sam + +import io.circe.generic.auto._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import sttp.tapir.generic.auto._ +import sttp.tapir.json.circe._ +import sttp.tapir.serverless.aws.sam.VerifySamTemplateTest._ +import sttp.tapir.{Endpoint, endpoint, path, _} + +import scala.io.Source + +class VerifySamTemplateTest extends AnyFunSuite with Matchers { + + test("should match the expected yaml with image source") { + val expectedYaml = load("image_source_template.yaml") + + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "PetApi", + source = ImageSource("image.repository:pet-api"), + memorySize = 1024 + ) + + val actualYaml = AwsSamInterpreter.toSamTemplate(List(getPetEndpoint, addPetEndpoint)).toYaml + + println(actualYaml) + + expectedYaml shouldBe noIndentation(actualYaml) + } + + test("should match the expected yaml with code source") { + val expectedYaml = load("code_source_template.yaml") + + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "PetApi", + source = CodeSource(runtime = "java11", codeUri = "/somewhere/pet-api.jar", "pet.api.Handler::handleRequest"), + memorySize = 1024 + ) + + val actualYaml = AwsSamInterpreter.toSamTemplate(List(getPetEndpoint, addPetEndpoint)).toYaml + + println(actualYaml) + + expectedYaml shouldBe noIndentation(actualYaml) + } + +} + +object VerifySamTemplateTest { + + case class Pet(name: String, species: String) + + val getPetEndpoint: Endpoint[Int, Unit, Pet, Any] = endpoint.get + .in("api" / "pets" / path[Int]("id")) + .out(jsonBody[Pet]) + + val addPetEndpoint: Endpoint[Pet, Unit, Unit, Any] = endpoint.post + .in("api" / "pets") + .in(jsonBody[Pet]) + + def load(fileName: String): String = { + noIndentation(Source.fromInputStream(getClass.getResourceAsStream(s"/$fileName")).getLines().mkString("\n")) + } + + def noIndentation(s: String): String = s.replaceAll("[ \t]", "").trim +} From a2451dd4b4727517cc11291c3314e386a5f640f4 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Fri, 14 May 2021 12:11:00 +0200 Subject: [PATCH 08/35] runnable example --- build.sbt | 20 +---- project/Versions.scala | 1 + project/plugins.sbt | 2 - .../examples/src/main/resources/event.json | 41 --------- .../aws/examples/HelloHandler.scala | 51 ----------- .../serverless/aws/examples/HelloSam.scala | 23 ----- .../aws/examples/LambdaApiExample.scala | 84 +++++++++++++++++++ .../aws/lambda/AwsServerInterpreter.scala | 4 +- .../aws/lambda/AwsServerOptions.scala | 6 +- .../aws/lambda/AwsToResponseBody.scala | 14 ++-- .../aws/sam/AwsSamTemplateEncoders.scala | 2 +- .../aws/sam/EndpointsToSamTemplate.scala | 25 +++--- .../sttp/tapir/serverless/aws/sam/model.scala | 12 +-- .../aws/sam/VerifySamTemplateTest.scala | 4 - 14 files changed, 121 insertions(+), 168 deletions(-) delete mode 100644 serverless/aws/examples/src/main/resources/event.json delete mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala delete mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala create mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala diff --git a/build.sbt b/build.sbt index cd43cdeb30..e079fc80ee 100644 --- a/build.sbt +++ b/build.sbt @@ -884,7 +884,7 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ .settings(commonJvmSettings) .settings( name := "tapir-aws-lambda-tests", - libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "1.0.0", + libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.lambdaInterface, assembly / assemblyJarName := "tapir-aws-lambda-tests.jar", assembly / test := {}, // no tests before building jar assembly / assemblyMergeStrategy := { @@ -921,27 +921,13 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .dependsOn(core, tests % Test) lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) - .enablePlugins(JavaAppPackaging, DockerPlugin) .settings(commonJvmSettings) .settings( - libraryDependencies += "com.amazonaws" % "aws-java-sdk-lambda" % "1.11.1014" + libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.lambdaInterface ) .settings( - assembly / assemblyJarName := "examples.jar", - version := "1.0.0", name := "tapir-aws-examples", - packageName in Docker := "tapir-aws-examples", - dockerBaseImage := "public.ecr.aws/lambda/java:11", - daemonUser in Docker := "daemon", - dockerUpdateLatest := true, - dockerCmd := List("com.softwaremill.sttp.tapir.serverless.aws.examples.HelloHandler::handleRequest"), -// dockerCommands := dockerCommands.value.filterNot { -// case ExecCmd("ENTRYPOINT", _) => true -// case _ => false -// }, - // https://hub.docker.com/r/amazon/aws-lambda-java - defaultLinuxInstallLocation in Docker := "/var/task", - dockerRepository := Some("localhost:5000") + assembly / assemblyJarName := "aws-examples.jar" ) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(awsLambda, awsSam) diff --git a/project/Versions.scala b/project/Versions.scala index e449dbff3d..a5fb8a114d 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -31,4 +31,5 @@ object Versions { val jwtScala = "5.0.0" val derevo = "0.12.5" val newtype = "0.4.4" + val lambdaInterface = "1.0.0" } diff --git a/project/plugins.sbt b/project/plugins.sbt index 225d98acb5..f354e352cb 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -10,6 +10,4 @@ addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.8.0") addSbtPlugin("org.jetbrains.scala" % "sbt-ide-settings" % "1.1.1") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.1") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") -// serverless -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.15.0") diff --git a/serverless/aws/examples/src/main/resources/event.json b/serverless/aws/examples/src/main/resources/event.json deleted file mode 100644 index 3bec355a84..0000000000 --- a/serverless/aws/examples/src/main/resources/event.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "version": "2.0", - "routeKey": "GET /hello", - "rawPath": "/hello", - "rawQueryString": "", - "headers": { - "accept": "*/*", - "content-length": "3", - "content-type": "application/x-www-form-urlencoded", - "host": "9abc9.execute-api.eu-central-1.amazonaws.com", - "user-agent": "curl/7.64.1", - "x-amzn-trace-id": "Root=1-60250d19-7182a3ff0e9dffb334e2bf74", - "x-forwarded-for": "78.11.177.136", - "x-forwarded-port": "443", - "x-forwarded-proto": "https" - }, - "queryStringParameters": { - "a": "123" - }, - "requestContext": { - "accountId": "1234567890", - "apiId": "9abc9", - "domainName": "9abc9.execute-api.eu-central-1.amazonaws.com", - "domainPrefix": "9abc9", - "http": { - "method": "GET", - "path": "/hello", - "protocol": "HTTP/1.1", - "sourceIp": "78.11.177.136", - "userAgent": "curl/7.64.1" - }, - "requestId": "ak78CiA8FiAEPWQ=", - "routeKey": "POST /hello", - "stage": "$default", - "time": "11/Feb/2021:10:55:21 +0000", - "timeEpoch": 1613040921706 - }, - "pathParameters": {}, - "body": "OTg3", - "isBase64Encoded": true -} \ No newline at end of file diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala deleted file mode 100644 index e19269114b..0000000000 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloHandler.scala +++ /dev/null @@ -1,51 +0,0 @@ -package sttp.tapir.serverless.aws.examples - -import cats.effect.IO -import cats.syntax.all._ -import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler} -import io.circe.Printer -import io.circe.generic.auto._ -import io.circe.parser.decode -import io.circe.syntax._ -import sttp.tapir._ -import sttp.tapir.server.ServerEndpoint -import sttp.tapir.serverless.aws.examples.HelloHandler.helloEndpoint -import sttp.tapir.serverless.aws.lambda._ - -import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} -import java.nio.charset.StandardCharsets - -class HelloHandler extends RequestStreamHandler { - - override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors[IO]() - - val route: Route[IO] = AwsServerInterpreter.toRoute(allTestE) - - val json = new String(input.readAllBytes(), StandardCharsets.UTF_8) - - val awsRequest = decode[AwsRequest](json).getOrElse(throw new Exception) - - val result: IO[Unit] = route(awsRequest) - .map { awsRes => - val writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)) - writer.write(Printer.noSpaces.print(awsRes.asJson)) - writer.flush() - } - - result.unsafeRunSync() - } -} - -object HelloHandler { - import io.circe.generic.auto._ - import sttp.tapir.generic.auto._ - import sttp.tapir.json.circe.jsonBody - - case class HelloResponse(msg: String) - - val helloEndpoint: ServerEndpoint[Unit, Unit, HelloResponse, Any, IO] = endpoint.get - .in("hello") - .out(jsonBody[HelloResponse]) - .serverLogic(_ => IO.pure(HelloResponse("Hello!").asRight[Unit])) -} diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala deleted file mode 100644 index 5b463e5ac2..0000000000 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/HelloSam.scala +++ /dev/null @@ -1,23 +0,0 @@ -package sttp.tapir.serverless.aws.examples - -import io.circe.syntax._ -import sttp.tapir.serverless.aws.sam._ - -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Paths} - -object HelloSam extends App { - val targetFile = "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/examples/template.yaml" - - implicit val samOptions: AwsSamOptions = AwsSamOptions( - "hello", - source = CodeSource("java11", "target/jvm-2.13/examples.jar", "sttp.tapir.serverless.aws.examples.HelloHandler::handleRequest") - ) - - val samTemplate = new AwsSamInterpreter().apply(List(HelloHandler.helloEndpoint.endpoint)) - - val yaml = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain) - .pretty(samTemplate.asJson(AwsSamTemplateEncoders.encoderSamTemplate)) - - Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) -} diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala new file mode 100644 index 0000000000..c87a5cf408 --- /dev/null +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala @@ -0,0 +1,84 @@ +package sttp.tapir.serverless.aws.examples + +import cats.effect.IO +import cats.syntax.all._ +import com.amazonaws.services.lambda.runtime.{Context, RequestStreamHandler} +import io.circe.Printer +import io.circe.generic.auto._ +import io.circe.parser.decode +import io.circe.syntax._ +import sttp.model.StatusCode +import sttp.tapir._ +import sttp.tapir.generic.auto._ +import sttp.tapir.json.circe.jsonBody +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.serverless.aws.examples.LambdaApiExample.{personEndpoint, route} +import sttp.tapir.serverless.aws.lambda._ +import sttp.tapir.serverless.aws.sam.{AwsSamInterpreter, AwsSamOptions, CodeSource} + +import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.{Files, Paths} + +/** Example assumes that you have `sam local` installed on your OS. Installation is simple and described here: + * https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html + */ +class LambdaApiExample extends RequestStreamHandler { + + override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { + + /** Read input as string */ + val json = new String(input.readAllBytes(), UTF_8) + + /** Decode input to `AwsRequest` which is send by API Gateway */ + (decode[AwsRequest](json) match { + /** Process request using interpreted route */ + case Right(awsRequest) => route(awsRequest) + case Left(_) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, "")) + }).map { awsRes => + println(awsRes.body) + /** Write response to output */ + val writer = new BufferedWriter(new OutputStreamWriter(output, UTF_8)) + writer.write(Printer.noSpaces.print(awsRes.asJson)) + writer.flush() + }.unsafeRunSync() + } +} + +object LambdaApiExample { + case class Person(name: String) + + val personEndpoint: ServerEndpoint[Person, Unit, String, Any, IO] = endpoint.post + .in("api" / "person") + .in(jsonBody[Person]) + .out(stringBody) + .serverLogic { person => IO.pure(s"Hello ${person.name}!".asRight[Unit]) } + + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) + + /** Persons api defined by our endpoint, it's a function `AwsRequest` -> `IO[AwsResponse]` */ + val route: Route[IO] = AwsServerInterpreter.toRoute(personEndpoint) +} + +/** Before running the actual example we need to interpret our api as SAM template */ +object LambdaApiExampleSamTemplate extends App { + + val jarPath = this.getClass.getProtectionDomain.getCodeSource.getLocation.getPath + .replace("classes/", "aws-examples.jar") + + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "PersonsApi", + source = + /** Specifying a fat jar build from our sources */ + CodeSource( + runtime = "java11", + codeUri = jarPath, + handler = "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest" + ) + ) + + val templateYaml = AwsSamInterpreter.toSamTemplate(personEndpoint).toYaml + + /** Write template to file, it's required to run the example using sam local */ + Files.write(Paths.get("template.yaml"), templateYaml.getBytes(UTF_8)) +} diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index bafbb142e9..35401d3c5f 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -2,7 +2,7 @@ package sttp.tapir.serverless.aws.lambda import cats.data.Kleisli import cats.effect.Sync -import sttp.model.{Header, StatusCode} +import sttp.model.StatusCode import sttp.monad.syntax._ import sttp.tapir.Endpoint import sttp.tapir.integ.cats.CatsMonadError @@ -34,7 +34,7 @@ trait AwsServerInterpreter { val serverRequest = new AwsServerRequest(request) val interpreter = new ServerInterpreter[Any, F, String, Nothing]( new AwsRequestBody[F](request), - AwsToResponseBody, + new AwsToResponseBody, serverOptions.interceptors, deleteFile = _ => ().unit // no file support ) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala index 5569156855..aa6c0a7573 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerOptions.scala @@ -7,13 +7,14 @@ import sttp.tapir.server.interceptor.exception.{DefaultExceptionHandler, Excepti import sttp.tapir.server.interceptor.log.ServerLogInterceptor import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor -case class AwsServerOptions[F[_]](interceptors: List[Interceptor[F, String]]) { +case class AwsServerOptions[F[_]](encodeResponseBody: Boolean = true, interceptors: List[Interceptor[F, String]]) { def prependInterceptor(i: Interceptor[F, String]): AwsServerOptions[F] = copy(interceptors = i :: interceptors) def appendInterceptor(i: Interceptor[F, String]): AwsServerOptions[F] = copy(interceptors = interceptors :+ i) } object AwsServerOptions { def customInterceptors[F[_], T]( + encodeResponseBody: Boolean = true, metricsInterceptor: Option[MetricsRequestInterceptor[F, String]] = None, exceptionHandler: Option[ExceptionHandler] = Some(DefaultExceptionHandler), serverLogInterceptor: Option[ServerLogInterceptor[T, F, String]] = None, @@ -23,7 +24,8 @@ object AwsServerOptions { ), decodeFailureHandler: DecodeFailureHandler = DefaultDecodeFailureHandler.handler ): AwsServerOptions[F] = AwsServerOptions( - metricsInterceptor.toList ++ + encodeResponseBody, + interceptors = metricsInterceptor.toList ++ exceptionHandler.map(new ExceptionInterceptor[F, String](_)).toList ++ serverLogInterceptor.toList ++ additionalInterceptors ++ diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala index ff5eb6d04e..ee1173cb3a 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala @@ -11,28 +11,28 @@ import java.nio.ByteBuffer import java.nio.charset.Charset import java.util.Base64 -private[lambda] object AwsToResponseBody extends ToResponseBody[String, Nothing] { +private[lambda] class AwsToResponseBody[F[_]](implicit options: AwsServerOptions[F]) extends ToResponseBody[String, Nothing] { override val streams: capabilities.Streams[Nothing] = NoStreams override def fromRawValue[R](v: R, headers: HasHeaders, format: CodecFormat, bodyType: RawBodyType[R]): String = bodyType match { case RawBodyType.StringBody(charset) => + println(options.encodeResponseBody) val str = v.asInstanceOf[String] - Base64.getEncoder.encodeToString(str.getBytes(charset)) + if (options.encodeResponseBody) Base64.getEncoder.encodeToString(str.getBytes(charset)) else new String(str.getBytes(charset)) case RawBodyType.ByteArrayBody => val bytes = v.asInstanceOf[Array[Byte]] - Base64.getEncoder.encodeToString(bytes) + if (options.encodeResponseBody) Base64.getEncoder.encodeToString(bytes) else new String(bytes) case RawBodyType.ByteBufferBody => val byteBuffer = v.asInstanceOf[ByteBuffer] - Base64.getEncoder.encodeToString(safeRead(byteBuffer)) + if (options.encodeResponseBody) Base64.getEncoder.encodeToString(safeRead(byteBuffer)) else new String(safeRead(byteBuffer)) case RawBodyType.InputStreamBody => val stream = v.asInstanceOf[InputStream] - Base64.getEncoder.encodeToString(stream.readAllBytes()) - - case RawBodyType.FileBody => throw new UnsupportedOperationException + if (options.encodeResponseBody) Base64.getEncoder.encodeToString(stream.readAllBytes()) else new String(stream.readAllBytes()) + case RawBodyType.FileBody => throw new UnsupportedOperationException case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException } diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala index d727225975..a0096ec026 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamTemplateEncoders.scala @@ -8,7 +8,7 @@ import scala.collection.immutable.ListMap object AwsSamTemplateEncoders { implicit def encodeListMap[V: Encoder]: Encoder[ListMap[String, V]] = { case m: ListMap[String, V] => - val properties = m.view.mapValues(v => implicitly[Encoder[V]].apply(v)).toList + val properties = m.view.map { case (k, v) => k -> implicitly[Encoder[V]].apply(v) }.toList Json.obj(properties: _*) } diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala index 01aaca75a5..b256cbb6bb 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala @@ -4,30 +4,31 @@ import sttp.model.Method import sttp.tapir.internal._ import sttp.tapir.{Endpoint, EndpointInput} -import scala.collection.immutable.ListMap - private[sam] object EndpointsToSamTemplate { def apply(es: List[Endpoint[_, _, _, _]])(implicit options: AwsSamOptions): SamTemplate = { val functionName = options.namePrefix + "Function" val httpApiName = options.namePrefix + "HttpApi" - val apiEvents = es.map(endpointNameMethodAndPath).map { case (name, method, path) => - name -> FunctionHttpApiEvent( - FunctionHttpApiEventProperties(s"!Ref $httpApiName", method.map(_.method).getOrElse("ANY"), path, options.timeout.toMillis) - ) - } + val apiEvents = es + .map(endpointNameMethodAndPath) + .map { case (name, method, path) => + name -> FunctionHttpApiEvent( + FunctionHttpApiEventProperties(s"!Ref $httpApiName", method.map(_.method).getOrElse("ANY"), path, options.timeout.toMillis) + ) + } + .toMap SamTemplate( - Resources = ListMap( + Resources = Map( functionName -> FunctionResource( options.source match { case ImageSource(imageUri) => - FunctionImageProperties(options.timeout.toSeconds, options.memorySize, ListMap.from(apiEvents), imageUri) + FunctionImageProperties(options.timeout.toSeconds, options.memorySize, apiEvents, imageUri) case cs @ CodeSource(_, _, _) => FunctionCodeProperties( options.timeout.toSeconds, options.memorySize, - ListMap.from(apiEvents), + apiEvents, cs.runtime, cs.codeUri, cs.handler @@ -36,10 +37,10 @@ private[sam] object EndpointsToSamTemplate { ), httpApiName -> HttpResource(HttpProperties("$default")) ), - Outputs = ListMap( + Outputs = Map( (options.namePrefix + "Url") -> Output( "Base URL of your endpoints", - ListMap("Fn::Sub" -> ("https://${" + httpApiName + "}.execute-api.${AWS::Region}.${AWS::URLSuffix}")) + Map("Fn::Sub" -> ("https://${" + httpApiName + "}.execute-api.${AWS::Region}.${AWS::URLSuffix}")) ) ) ) diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala index db2f17a171..f9a3ebef87 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala @@ -8,8 +8,8 @@ import scala.collection.immutable.ListMap case class SamTemplate( AWSTemplateFormatVersion: String = "2010-09-09", Transform: String = "AWS::Serverless-2016-10-31", - Resources: ListMap[String, Resource], - Outputs: ListMap[String, Output] + Resources: Map[String, Resource], + Outputs: Map[String, Output] ) { def toYaml: String = { val template: SamTemplate = this @@ -28,13 +28,13 @@ trait Properties trait FunctionProperties { val Timeout: Long val MemorySize: Int - val Events: ListMap[String, FunctionHttpApiEvent] + val Events: Map[String, FunctionHttpApiEvent] } case class FunctionImageProperties( Timeout: Long, MemorySize: Int, - Events: ListMap[String, FunctionHttpApiEvent], + Events: Map[String, FunctionHttpApiEvent], ImageUri: String, PackageType: String = "Image" ) extends Properties @@ -43,7 +43,7 @@ case class FunctionImageProperties( case class FunctionCodeProperties( Timeout: Long, MemorySize: Int, - Events: ListMap[String, FunctionHttpApiEvent], + Events: Map[String, FunctionHttpApiEvent], Runtime: String, CodeUri: String, Handler: String @@ -62,4 +62,4 @@ case class FunctionHttpApiEventProperties( PayloadFormatVersion: String = "2.0" ) -case class Output(Description: String, Value: ListMap[String, String]) +case class Output(Description: String, Value: Map[String, String]) diff --git a/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala b/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala index 861f0d0d2e..145fbe76cf 100644 --- a/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala +++ b/serverless/aws/sam/src/test/scala/sttp/tapir/serverless/aws/sam/VerifySamTemplateTest.scala @@ -23,8 +23,6 @@ class VerifySamTemplateTest extends AnyFunSuite with Matchers { val actualYaml = AwsSamInterpreter.toSamTemplate(List(getPetEndpoint, addPetEndpoint)).toYaml - println(actualYaml) - expectedYaml shouldBe noIndentation(actualYaml) } @@ -39,8 +37,6 @@ class VerifySamTemplateTest extends AnyFunSuite with Matchers { val actualYaml = AwsSamInterpreter.toSamTemplate(List(getPetEndpoint, addPetEndpoint)).toYaml - println(actualYaml) - expectedYaml shouldBe noIndentation(actualYaml) } From 64922f50531b0731c598e90b3a1c986176cce4c3 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Fri, 14 May 2021 17:49:50 +0200 Subject: [PATCH 09/35] adjustments and docs --- build.sbt | 28 +++++++++++++---- doc/server/aws.md | 24 +++++++++++++++ .../tapir/server/stub/SttpStubServer.scala | 5 ---- .../aws/examples/LambdaApiExample.scala | 28 ++++++++--------- .../aws/lambda/tests/LambdaHandler.scala | 2 +- .../aws/lambda/tests/LambdaSamTemplate.scala | 10 ++++--- .../serverless/aws/lambda/tests/package.scala | 12 ++++++++ .../tests/AwsLambdaSamLocalHttpTest.scala | 25 ++++++++-------- .../lambda/tests/AwsLambdaStubHttpTest.scala | 3 +- .../tests/AwsLambdaStubTestServer.scala | 30 +++++++------------ .../aws/lambda/AwsServerInterpreter.scala | 6 ++-- .../aws/lambda/AwsServerRequest.scala | 6 +++- .../aws/lambda/AwsToResponseBody.scala | 3 +- 13 files changed, 111 insertions(+), 71 deletions(-) create mode 100644 doc/server/aws.md diff --git a/build.sbt b/build.sbt index e079fc80ee..045099eafa 100644 --- a/build.sbt +++ b/build.sbt @@ -878,7 +878,6 @@ lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambd .dependsOn(core, cats, circeJson, awsSam) lazy val sam = Process("sam local start-api --warm-containers EAGER").run() -lazy val samTemplate = taskKey[Unit]("Generate sam template for lambda tests") lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda-tests")) .settings(commonJvmSettings) @@ -891,16 +890,15 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first case x => (assembly / assemblyMergeStrategy).value(x) }, - samTemplate := (Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate").value, Test / test := (Test / test) - .dependsOn(samTemplate) + .dependsOn((Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate")) .dependsOn(assembly) .value, Test / testOptions += Tests.Setup(() => { - val ok = PollingUtils.poll(10.seconds, 1.second) { + val samReady = PollingUtils.poll(10.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) } - if (!ok) sam.destroy() + if (!samReady) sam.destroy() }), Test / testOptions += Tests.Cleanup(() => sam.destroy()), Test / parallelExecution := false @@ -920,6 +918,8 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, tests % Test) +lazy val runAwsExample = taskKey[Unit]("runs aws lambda example on sam local") + lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) .settings(commonJvmSettings) .settings( @@ -927,7 +927,23 @@ lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/exa ) .settings( name := "tapir-aws-examples", - assembly / assemblyJarName := "aws-examples.jar" + assembly / assemblyJarName := "tapir-aws-examples.jar", + runAwsExample := { + val log = sLog.value + (Compile / runMain).toTask(" sttp.tapir.serverless.aws.examples.LambdaApiExampleSamTemplate").value + val samReady = PollingUtils.poll(10.seconds, 1.second) { + sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/api/hello")) + } + if (!samReady) { + sam.destroy() + log.info("Failed to start sam local-api, have your run sbt assembly?") + } else { + log.info("Lambda function is available under http://127.0.0.1:3000/api/hello ...") + log.info("Press any key to exit ...") + scala.io.StdIn.readLine() + sam.destroy() + } + } ) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(awsLambda, awsSam) diff --git a/doc/server/aws.md b/doc/server/aws.md new file mode 100644 index 0000000000..bd1db3231b --- /dev/null +++ b/doc/server/aws.md @@ -0,0 +1,24 @@ +# Running behind AWS API Gateway + +[AWS API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) provides a proxy integration +with [AWS Lambda](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) which allows +you to implement API routes using Lambda functions. [AWS SAM](https://aws.amazon.com/serverless/sam/) on the other hand provides a configuration mechanism +for binding HTTP APIs to Lambda functions. + +This concept of serverless API has been adapted to Tapir in form of two components. + +The first one is `AwsServerInterpreter` which routes AWS API Gateway requests to responses just as any other server interpreter does. +It should be used in your lambda function code. +```scala +"com.softwaremill.sttp.tapir" %% "tapir-aws-lambda" % "@VERSION@" +``` + +The second one is `AwsSamInterpreter` which interprets Tapir `Endpoints` into AWS SAM configuration file. +It should be used to configure your API Gateway. +```scala +"com.softwaremill.sttp.tapir" %% "tapir-aws-sam" % "@VERSION@" +``` + +In our [GitHub repository](https://github.com/softwaremill/tapir/tree/master/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample) +you'll find a runnable example which uses [AWS SAM command line tool](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-command-reference.html) +to run a "hello world" serverless application locally. diff --git a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala index 034fd8e6d3..a1003ce77b 100644 --- a/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala +++ b/server/sttp-stub-server/src/main/scala/sttp/tapir/server/stub/SttpStubServer.scala @@ -38,11 +38,6 @@ trait SttpStubServer { ): SttpBackendStub[F, R] = _whenInputMatches(endpoint.endpoint)(inputMatcher).thenRespondF(req => interpretRequest(req, endpoint, interceptors)) - def whenRequestMatchesEndpointThenInterpret[I, E, O]( - endpoint: Endpoint[I, E, O, R], - interpret: Request[_, _] => F[Response[_]] - ): SttpBackendStub[F, R] = _whenRequestMatches(endpoint).thenRespondF(interpret(_)) - private def _whenRequestMatches[E, O](endpoint: Endpoint[_, E, O, _]): stub.WhenRequest = { new stub.WhenRequest(req => DecodeBasicInputs(endpoint.input, new SttpRequest(req)) match { diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala index c87a5cf408..cbabe13d5d 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala @@ -9,10 +9,8 @@ import io.circe.parser.decode import io.circe.syntax._ import sttp.model.StatusCode import sttp.tapir._ -import sttp.tapir.generic.auto._ -import sttp.tapir.json.circe.jsonBody import sttp.tapir.server.ServerEndpoint -import sttp.tapir.serverless.aws.examples.LambdaApiExample.{personEndpoint, route} +import sttp.tapir.serverless.aws.examples.LambdaApiExample.{helloEndpoint, route} import sttp.tapir.serverless.aws.lambda._ import sttp.tapir.serverless.aws.sam.{AwsSamInterpreter, AwsSamOptions, CodeSource} @@ -20,8 +18,11 @@ import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} import java.nio.charset.StandardCharsets.UTF_8 import java.nio.file.{Files, Paths} -/** Example assumes that you have `sam local` installed on your OS. Installation is simple and described here: +/** Example assumes that you have `sam local` installed on your OS. Installation is described here: * https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html + * + * Select `awsExamples` project from sbt shell and run `assembly` task to build a fat jar with lambda handler. + * Then `runSamExample` to generate `template.yaml` and start up `sam local`. */ class LambdaApiExample extends RequestStreamHandler { @@ -36,7 +37,6 @@ class LambdaApiExample extends RequestStreamHandler { case Right(awsRequest) => route(awsRequest) case Left(_) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, "")) }).map { awsRes => - println(awsRes.body) /** Write response to output */ val writer = new BufferedWriter(new OutputStreamWriter(output, UTF_8)) writer.write(Printer.noSpaces.print(awsRes.asJson)) @@ -46,30 +46,26 @@ class LambdaApiExample extends RequestStreamHandler { } object LambdaApiExample { - case class Person(name: String) - val personEndpoint: ServerEndpoint[Person, Unit, String, Any, IO] = endpoint.post - .in("api" / "person") - .in(jsonBody[Person]) + val helloEndpoint: ServerEndpoint[Unit, Unit, String, Any, IO] = endpoint.get + .in("api" / "hello") .out(stringBody) - .serverLogic { person => IO.pure(s"Hello ${person.name}!".asRight[Unit]) } + .serverLogic { _ => IO.pure("Hello!".asRight[Unit]) } implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) - /** Persons api defined by our endpoint, it's a function `AwsRequest` -> `IO[AwsResponse]` */ - val route: Route[IO] = AwsServerInterpreter.toRoute(personEndpoint) + val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) } /** Before running the actual example we need to interpret our api as SAM template */ object LambdaApiExampleSamTemplate extends App { - val jarPath = this.getClass.getProtectionDomain.getCodeSource.getLocation.getPath - .replace("classes/", "aws-examples.jar") + val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString implicit val samOptions: AwsSamOptions = AwsSamOptions( "PersonsApi", source = - /** Specifying a fat jar build from our sources */ + /** Specifying a fat jar build from example sources */ CodeSource( runtime = "java11", codeUri = jarPath, @@ -77,7 +73,7 @@ object LambdaApiExampleSamTemplate extends App { ) ) - val templateYaml = AwsSamInterpreter.toSamTemplate(personEndpoint).toYaml + val templateYaml = AwsSamInterpreter.toSamTemplate(helloEndpoint).toYaml /** Write template to file, it's required to run the example using sam local */ Files.write(Paths.get("template.yaml"), templateYaml.getBytes(UTF_8)) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 8651ffdb89..82e63835ea 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -15,7 +15,7 @@ import java.nio.charset.StandardCharsets object LambdaHandler extends RequestStreamHandler { override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) allEndpoints.foreach(e => println(e.endpoint.showDetail)) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala index 3051d564f5..05a1b0b5d2 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala @@ -2,20 +2,22 @@ package sttp.tapir.serverless.aws.lambda.tests import sttp.tapir.serverless.aws.sam._ -import java.nio.charset.StandardCharsets +import java.nio.charset.StandardCharsets.UTF_8 import java.nio.file.{Files, Paths} object LambdaSamTemplate extends App { + + val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString + implicit val samOptions: AwsSamOptions = AwsSamOptions( "Tests", source = CodeSource( "java11", - "/Users/kubinio/Desktop/workspace/tapir/serverless/aws/lambda-tests/target/jvm-2.13/tapir-aws-lambda-tests.jar", + jarPath, "sttp.tapir.serverless.aws.lambda.tests.LambdaHandler::handleRequest" ), memorySize = 1024 ) val yaml = AwsSamInterpreter.toSamTemplate(allEndpoints.map(_.endpoint).toList).toYaml - val targetFile = "/Users/kubinio/Desktop/workspace/tapir/template.yaml" - Files.write(Paths.get(targetFile), yaml.getBytes(StandardCharsets.UTF_8)) + Files.write(Paths.get("template.yaml"), yaml.getBytes(UTF_8)) } diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala index 514755093d..77cd5a631b 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/package.scala @@ -6,8 +6,11 @@ import com.softwaremill.macwire.wireSet import sttp.model.Header import sttp.tapir._ import sttp.tapir.server.ServerEndpoint +import sttp.tapir.tests.TestUtil.inputStreamToByteArray import sttp.tapir.tests._ +import java.io.{ByteArrayInputStream, InputStream} + package object tests { // this endpoint is used to wait until sam local starts up before running actual tests @@ -27,5 +30,14 @@ package object tests { headers => IO.pure(headers.asRight[Unit]) } + val in_input_stream_out_input_stream_endpoint: ServerEndpoint[InputStream, Unit, InputStream, Any, IO] = + in_input_stream_out_input_stream.in("is").serverLogic { is => + IO.pure((new ByteArrayInputStream(inputStreamToByteArray(is)): InputStream).asRight[Unit]) + } + + val in_4query_out_4header_extended_endpoint + : ServerEndpoint[((String, String), String, String), Unit, ((String, String), String, String), Any, IO] = + in_4query_out_4header_extended.in("echo" / "query").serverLogic { in => IO.pure(in.asRight[Unit]) } + val allEndpoints: Set[ServerEndpoint[_, _, _, Any, IO]] = wireSet[ServerEndpoint[_, _, _, Any, IO]] } diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala index e40779d5ac..dd05ff9ccb 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala @@ -12,8 +12,6 @@ import sttp.model.{Header, Uri} import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.tests.backendResource -import java.util.Base64 - class AwsLambdaSamLocalHttpTest extends AnyFunSuite { private val baseUri: Uri = uri"http://localhost:3000" @@ -21,20 +19,14 @@ class AwsLambdaSamLocalHttpTest extends AnyFunSuite { testServer(in_path_path_out_string_endpoint) { backend => basicRequest.get(uri"$baseUri/fruit/orange/amount/20").send(backend).map { req => req.body - .map(b => decode(b) shouldBe "orange 20") + .map(_ shouldBe "orange 20") .getOrElse(Assertions.fail()) } } - testServer(in_path_path_out_string_endpoint, "with URL encoding") { backend => - basicRequest.get(uri"$baseUri/fruit/apple%2Fred/amount/20").send(backend).map { req => - req.body.map(b => decode(b) shouldBe "apple/red 20").getOrElse(Assertions.fail()) - } - } - testServer(in_string_out_string_endpoint) { backend => basicRequest.post(uri"$baseUri/api/echo/string").body("Sweet").send(backend).map { req => - req.body.map(b => decode(b) shouldBe "Sweet").getOrElse(Assertions.fail()) + req.body.map(_ shouldBe "Sweet").getOrElse(Assertions.fail()) } } @@ -45,7 +37,7 @@ class AwsLambdaSamLocalHttpTest extends AnyFunSuite { .send(backend) .map { req => req.body - .map(b => decode(b) shouldBe """{"fruit":"orange","amount":11}""") + .map(_ shouldBe """{"fruit":"orange","amount":11}""") .getOrElse(Assertions.fail()) } } @@ -58,7 +50,16 @@ class AwsLambdaSamLocalHttpTest extends AnyFunSuite { .map(_.headers should contain allOf (Header.unsafeApply("X-Fruit", "apple"), Header.unsafeApply("Y-Fruit", "Orange"))) } - private def decode(enc: String): String = new String(Base64.getDecoder.decode(enc)) + testServer(in_input_stream_out_input_stream_endpoint) { backend => + basicRequest.post(uri"$baseUri/api/echo/is").body("mango").send(backend).map(_.body shouldBe Right("mango")) + } + + testServer(in_4query_out_4header_extended_endpoint) { backend => + basicRequest + .get(uri"$baseUri?a=1&b=2&x=3&y=4") + .send(backend) + .map(_.headers.map(h => h.name -> h.value).toSet should contain allOf ("A" -> "1", "B" -> "2", "X" -> "3", "Y" -> "4")) + } private def testServer(t: ServerEndpoint[_, _, _, Any, IO], suffix: String = "")( f: SttpBackend[IO, Fs2Streams[IO] with WebSockets] => IO[Assertion] diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala index 63ae9427fc..2cea9bfa31 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala @@ -31,6 +31,7 @@ object AwsLambdaStubHttpTest { metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] ): Route[IO] = { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( + encodeResponseBody = false, metricsInterceptor = metricsInterceptor, decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) ) @@ -39,7 +40,7 @@ object AwsLambdaStubHttpTest { override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit eClassTag: ClassTag[E] ): Route[IO] = { - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) AwsServerInterpreter.toRouteRecoverErrors(e)(fn) } override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = ??? diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala index 7e9a831389..f9c043d7ad 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala @@ -12,7 +12,6 @@ import sttp.tapir.integ.cats.CatsMonadError import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor -import sttp.tapir.server.stub._ import sttp.tapir.server.tests.TestServer import sttp.tapir.serverless.aws.lambda._ import sttp.tapir.serverless.aws.lambda.tests.AwsLambdaStubTestServer._ @@ -29,37 +28,23 @@ class AwsLambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] )(fn: I => IO[Either[E, O]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( + encodeResponseBody = false, metricsInterceptor = metricsInterceptor, decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) ) val se: ServerEndpoint[I, E, O, Any, IO] = e.serverLogic(fn) val route: Route[IO] = AwsServerInterpreter.toRoute(se) - val backend: SttpBackendStub[IO, Any] = - SttpBackendStub(catsMonadIO).whenAnyRequest - .thenRespondF { request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) } - val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) - - Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) + Test(name)(runTest(stubBackend(route), uri"http://localhost:3000").unsafeRunSync()) } override def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, Any, IO], testNameSuffix: String)( runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion] ): Test = { - implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) val route: Route[IO] = AwsServerInterpreter.toRoute(e) - val backend = - SttpBackendStub(catsMonadIO) - .whenRequestMatchesEndpointThenInterpret( - e.endpoint, - request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) - ) - .whenAnyRequest - .thenRespondNotFound() - val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) - - Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) + Test(name)(runTest(stubBackend(route), uri"http://localhost:3000").unsafeRunSync()) } override def testServer(name: String, rs: => NonEmptyList[Route[IO]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { @@ -72,6 +57,11 @@ class AwsLambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { } Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) } + + private def stubBackend(route: Route[IO]): SttpBackend[IO, Any] = + SttpBackendStub(catsMonadIO).whenAnyRequest.thenRespondF { request => + route(sttpToAwsRequest(request)).map(awsToSttpResponse) + } } object AwsLambdaStubTestServer { @@ -108,7 +98,7 @@ object AwsLambdaStubTestServer { def awsToSttpResponse(response: AwsResponse): Response[String] = client3.Response( - new String(Base64.getDecoder.decode(response.body)), + new String(response.body), new StatusCode(response.statusCode), "", response.headers.map { case (n, v) => v.split(",").map(Header(n, _)) }.flatten.toSeq diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index 35401d3c5f..9518b64bfa 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -40,11 +40,11 @@ trait AwsServerInterpreter { ) interpreter.apply(serverRequest, ses).map { - case None => AwsResponse(Nil, isBase64Encoded = true, StatusCode.NotFound.code, Map.empty, "") + case None => AwsResponse(Nil, isBase64Encoded = serverOptions.encodeResponseBody, StatusCode.NotFound.code, Map.empty, "") case Some(res) => val cookies = res.cookies.collect { case Right(cookie) => cookie.value }.toList - val headers = res.headers.groupMapReduce(_.name)(_.value)((v1, v2) => s"$v1,$v2") - AwsResponse(cookies, isBase64Encoded = true, res.code.code, headers, res.body.getOrElse("")) + val headers = res.headers.groupBy(_.name).map { case (n, v) => n -> v.map(_.value).mkString(",") } + AwsResponse(cookies, isBase64Encoded = serverOptions.encodeResponseBody, res.code.code, headers, res.body.getOrElse("")) } } } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala index 601e5dddbd..06553facf1 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerRequest.scala @@ -4,6 +4,7 @@ import sttp.model.{Header, Method, QueryParams, Uri} import sttp.tapir.model.{ConnectionInfo, ServerRequest} import java.net.{InetSocketAddress, URLDecoder} +import scala.collection.immutable.Seq private[lambda] class AwsServerRequest(request: AwsRequest) extends ServerRequest { private val sttpUri: Uri = { @@ -21,5 +22,8 @@ private[lambda] class AwsServerRequest(request: AwsRequest) extends ServerReques override def queryParameters: QueryParams = sttpUri.params override def method: Method = Method.unsafeApply(request.requestContext.http.method) override def uri: Uri = sttpUri - override def headers: Seq[Header] = request.headers.map { case (n, v) => Header(n, v) }.toSeq + override def headers: Seq[Header] = request.headers + .map { case (n, v) => Header(n, v) } + .toSeq + .asInstanceOf[scala.collection.immutable.Seq[Header]] } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala index ee1173cb3a..94164b9567 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala @@ -16,9 +16,8 @@ private[lambda] class AwsToResponseBody[F[_]](implicit options: AwsServerOptions override def fromRawValue[R](v: R, headers: HasHeaders, format: CodecFormat, bodyType: RawBodyType[R]): String = bodyType match { case RawBodyType.StringBody(charset) => - println(options.encodeResponseBody) val str = v.asInstanceOf[String] - if (options.encodeResponseBody) Base64.getEncoder.encodeToString(str.getBytes(charset)) else new String(str.getBytes(charset)) + if (options.encodeResponseBody) Base64.getEncoder.encodeToString(str.getBytes(charset)) else str case RawBodyType.ByteArrayBody => val bytes = v.asInstanceOf[Array[Byte]] From 6d43016a48411d981fcd3e4d72f923b525ebb2e4 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 09:02:56 +0200 Subject: [PATCH 10/35] runtime added --- build.sbt | 23 +++- .../aws/lambda/tests/LambdaSamTemplate.scala | 2 +- .../tests/AwsLambdaSamLocalHttpTest.scala | 2 +- .../aws/lambda/runtime/AwsLambdaRuntime.scala | 111 ++++++++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala diff --git a/build.sbt b/build.sbt index 045099eafa..38479d617f 100644 --- a/build.sbt +++ b/build.sbt @@ -873,7 +873,14 @@ lazy val zioServer: ProjectMatrix = (projectMatrix in file("server/zio-http4s-se lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda")) .settings(commonJvmSettings) - .settings(name := "tapir-aws-lambda") + .settings( + name := "tapir-aws-lambda", + libraryDependencies ++= loggerDependencies, + libraryDependencies ++= Seq( + "com.softwaremill.sttp.client3" %% "http4s-ce2-backend" % Versions.sttp, + "org.http4s" %% "http4s-blaze-client" % Versions.http4s + ) + ) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, cats, circeJson, awsSam) @@ -895,10 +902,14 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ .dependsOn(assembly) .value, Test / testOptions += Tests.Setup(() => { + val log = sLog.value val samReady = PollingUtils.poll(10.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) } - if (!samReady) sam.destroy() + if (!samReady) { + sam.destroy() + log.err("Failed to start sam local-api") + } }), Test / testOptions += Tests.Cleanup(() => sam.destroy()), Test / parallelExecution := false @@ -918,7 +929,7 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, tests % Test) -lazy val runAwsExample = taskKey[Unit]("runs aws lambda example on sam local") +lazy val runSamExample = taskKey[Unit]("runs aws lambda example on sam local") lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) .settings(commonJvmSettings) @@ -928,15 +939,15 @@ lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/exa .settings( name := "tapir-aws-examples", assembly / assemblyJarName := "tapir-aws-examples.jar", - runAwsExample := { + runSamExample := { val log = sLog.value (Compile / runMain).toTask(" sttp.tapir.serverless.aws.examples.LambdaApiExampleSamTemplate").value - val samReady = PollingUtils.poll(10.seconds, 1.second) { + val samReady = PollingUtils.poll(20.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/api/hello")) } if (!samReady) { sam.destroy() - log.info("Failed to start sam local-api, have your run sbt assembly?") + log.err("Failed to start sam local-api, have your run sbt assembly?") } else { log.info("Lambda function is available under http://127.0.0.1:3000/api/hello ...") log.info("Press any key to exit ...") diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala index 05a1b0b5d2..61da8e63e8 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaSamTemplate.scala @@ -7,7 +7,7 @@ import java.nio.file.{Files, Paths} object LambdaSamTemplate extends App { - val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString + val jarPath = Paths.get("serverless/aws/lambda-tests/target/jvm-2.13/tapir-aws-lambda-tests.jar").toAbsolutePath.toString implicit val samOptions: AwsSamOptions = AwsSamOptions( "Tests", diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala index dd05ff9ccb..9552bc8cc2 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala @@ -56,7 +56,7 @@ class AwsLambdaSamLocalHttpTest extends AnyFunSuite { testServer(in_4query_out_4header_extended_endpoint) { backend => basicRequest - .get(uri"$baseUri?a=1&b=2&x=3&y=4") + .get(uri"$baseUri/echo/query?a=1&b=2&x=3&y=4") .send(backend) .map(_.headers.map(h => h.name -> h.value).toSet should contain allOf ("A" -> "1", "B" -> "2", "X" -> "3", "Y" -> "4")) } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala new file mode 100644 index 0000000000..4a5bc7e02b --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala @@ -0,0 +1,111 @@ +package sttp.tapir.serverless.aws.lambda.runtime + +import cats.effect.{Blocker, ConcurrentEffect, ContextShift, Resource} +import cats.syntax.either._ +import com.typesafe.scalalogging.StrictLogging +import io.circe.Printer +import io.circe.generic.auto._ +import io.circe.parser.decode +import io.circe.syntax._ +import org.http4s.client.blaze.BlazeClientBuilder +import sttp.client3._ +import sttp.client3.http4s.Http4sBackend +import sttp.monad.MonadError +import sttp.monad.syntax._ +import sttp.tapir.integ.cats.CatsMonadError +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.serverless.aws.lambda._ + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration.DurationInt + +// loosely based on https://github.com/carpe/scalambda/blob/master/native/src/main/scala/io/carpe/scalambda/native/ScalambdaIO.scala +abstract class AwsLambdaRuntime[F[_]: ConcurrentEffect: ContextShift] extends StrictLogging { + implicit def executionContext: ExecutionContext + + implicit def serverOptions: AwsServerOptions[F] = AwsServerOptions.customInterceptors() + def endpoints: Iterable[ServerEndpoint[_, _, _, Any, F]] + + protected val backend: Resource[F, SttpBackend[F, Any]] = Http4sBackend.usingBlazeClientBuilder( + BlazeClientBuilder[F](executionContext).withConnectTimeout(0.seconds), + Blocker.liftExecutionContext(implicitly) + ) + + def main(args: Array[String]): Unit = { + val route: Route[F] = AwsServerInterpreter.toRoute(endpoints.toList) + implicit val monad: MonadError[F] = new CatsMonadError[F] + + val runtimeApiInvocationUri = uri"http://${sys.env("AWS_LAMBDA_RUNTIME_API")}/2018-06-01/runtime/invocation" + val nextEventRequest = basicRequest.get(uri"$runtimeApiInvocationUri/next").response(asStringAlways).readTimeout(0.seconds) + + val pollEvent: F[RequestEvent] = { + logger.info(s"Fetching request event") + backend + .use(nextEventRequest.send(_)) + .flatMap { response => + response.header("lambda-runtime-aws-request-id") match { + case Some(id) => RequestEvent(id, response.body).unit + case _ => + monad.error[RequestEvent](new RuntimeException(s"Missing lambda-runtime-aws-request-id header in request event $response")) + } + } + .handleError { e => + logger.error("Failed to fetch request event", e) + monad.error(e) + } + } + + val decodeEvent: RequestEvent => F[AwsRequest] = event => { + decode[AwsRequest](event.body) match { + case Right(awsRequest) => awsRequest.unit + case Left(e) => + logger.error(s"Failed to decode request event ${event.requestId}", e.getCause) + monad.error(e.getCause) + } + } + + val routeRequest: (RequestEvent, AwsRequest) => F[Either[Throwable, AwsResponse]] = (event, request) => + route(request).map(_.asRight[Throwable]).handleError { case e => + logger.error(s"Failed to process request event ${event.requestId}", e) + e.asLeft[AwsResponse].unit + } + + val sendResponse: (RequestEvent, AwsResponse) => F[Unit] = (event, response) => + backend + .use { b => + basicRequest + .post(uri"$runtimeApiInvocationUri/${event.requestId}/response") + .body(Printer.noSpaces.print(response.asJson)) + .send(b) + } + .map(_ => ()) + + val sendError: (RequestEvent, Throwable) => F[Unit] = (event, e) => + backend + .use { b => + basicRequest.post(uri"$runtimeApiInvocationUri/${event.requestId}/error").body(e.getMessage).send(b) + } + .map(_ => ()) + + val sendResult: (RequestEvent, Either[Throwable, AwsResponse]) => F[Unit] = (event, result) => + result match { + case Right(response) => + logger.info(s"Request event ${event.requestId} completed successfully") + sendResponse(event, response) + case Left(e) => + logger.error(s"Request event ${event.requestId} failed", e) + sendError(event, e) + } + + val loop = for { + event <- pollEvent + request <- decodeEvent(event) + result <- routeRequest(event, request) + _ <- sendResult(event, result) + } yield () + + while (true) ConcurrentEffect[F].toIO(loop).unsafeRunSync() + } +} + +case class RequestEvent(requestId: String, body: String) From d344f9fe8cb96d80cfea423a4a18a8c8f1124213 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 09:29:33 +0200 Subject: [PATCH 11/35] merge & compile fix --- .../main/scala/sttp/tapir/server/tests/ServerBasicTests.scala | 2 +- .../tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala index 2b06e6f615..13194c78ee 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala @@ -385,7 +385,7 @@ class ServerBasicTests[F[_], ROUTE, B]( basicRequest.get(uri"$baseUri/api/echo/hello").send(backend).map(_.code shouldBe StatusCode.NotFound) >> basicRequest.get(uri"$baseUri/api/echo/").send(backend).map(_.code shouldBe StatusCode.NotFound) }, - testServer(in_string_out_status, "custom status code")((_: String) => pureResult(StatusCode(470).asRight[Unit])) { baseUri => + testServer(in_string_out_status, "custom status code")((_: String) => pureResult(StatusCode(470).asRight[Unit])) { (backend, baseUri) => basicRequest.get(uri"$baseUri?fruit=apple").send(backend).map(_.code shouldBe StatusCode(470)) }, testServer(in_string_out_status_from_string)((v: String) => pureResult((if (v == "apple") Right("x") else Left(10)).asRight[Unit])) { diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala index 4a5bc7e02b..64fa14668b 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala @@ -49,7 +49,7 @@ abstract class AwsLambdaRuntime[F[_]: ConcurrentEffect: ContextShift] extends St monad.error[RequestEvent](new RuntimeException(s"Missing lambda-runtime-aws-request-id header in request event $response")) } } - .handleError { e => + .handleError { case e => logger.error("Failed to fetch request event", e) monad.error(e) } From fb68ef6761484d50dc33f8668ce0140391a50ac6 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 11:12:02 +0200 Subject: [PATCH 12/35] try to install sam on ci --- .github/workflows/ci.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 590e10c481..ec3a085f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,14 +33,23 @@ jobs: key: sbt-cache-${{ runner.os }}-${{ matrix.target-platform }}-${{ hashFiles('project/build.properties') }} - name: Compile run: sbt -v compile compileDocumentation + - name: Install sam cli + run: | + wget https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip + unzip aws-sam-cli-linux-x86_64.zip -d sam-installation + ./sam-installation/install + sam -- version - name: Test if: matrix.target-platform != 'JS' - run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} +# run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} + run: | + sbt project awsLambdaTests + sbt test # Temporarily call JS tests for each subproject explicitly as a workaround until # https://github.com/scala-js/scala-js/issues/4317 has a solution - - name: Test Scala.js - if: matrix.target-platform == 'JS' - run: sbt coreJS/test coreJS2_12/test catsJS/test catsJS2_12/test enumeratumJS/test enumeratumJS2_12/test refinedJS/test refinedJS2_12/test circeJsonJS/test circeJsonJS2_12/test playJsonJS/test playJsonJS2_12/test uPickleJsonJS/test uPickleJsonJS2_12/test jsoniterScalaJS/test jsoniterScalaJS2_12/test sttpClientJS/test sttpClientJS2_12/test +# - name: Test Scala.js +# if: matrix.target-platform == 'JS' +# run: sbt coreJS/test coreJS2_12/test catsJS/test catsJS2_12/test enumeratumJS/test enumeratumJS2_12/test refinedJS/test refinedJS2_12/test circeJsonJS/test circeJsonJS2_12/test playJsonJS/test playJsonJS2_12/test uPickleJsonJS/test uPickleJsonJS2_12/test jsoniterScalaJS/test jsoniterScalaJS2_12/test sttpClientJS/test sttpClientJS2_12/test - name: Cleanup run: | rm -rf "$HOME/.ivy2/local" || true From 4eb3cc57619a9ab44e4820d0975973feb37ae7ad Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 11:30:35 +0200 Subject: [PATCH 13/35] try to install sam on ci (sudo privileges) --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec3a085f0a..2667a6ae84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,14 +31,14 @@ jobs: ~/.ivy2/cache ~/.coursier key: sbt-cache-${{ runner.os }}-${{ matrix.target-platform }}-${{ hashFiles('project/build.properties') }} - - name: Compile - run: sbt -v compile compileDocumentation - name: Install sam cli run: | - wget https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip - unzip aws-sam-cli-linux-x86_64.zip -d sam-installation - ./sam-installation/install - sam -- version + wget https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip > /dev/null + unzip aws-sam-cli-linux-x86_64.zip -d sam-installation > /dev/null + sudo ./sam-installation/install + sam --version + - name: Compile + run: sbt -v compile compileDocumentation - name: Test if: matrix.target-platform != 'JS' # run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} From b6ae64e5896a11c072ff05055ab10cc554680cd8 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 11:51:20 +0200 Subject: [PATCH 14/35] try to install sam on ci (less output) --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2667a6ae84..6216a8e6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,8 @@ jobs: key: sbt-cache-${{ runner.os }}-${{ matrix.target-platform }}-${{ hashFiles('project/build.properties') }} - name: Install sam cli run: | - wget https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip > /dev/null - unzip aws-sam-cli-linux-x86_64.zip -d sam-installation > /dev/null + wget -q https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip + unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation sudo ./sam-installation/install sam --version - name: Compile @@ -42,9 +42,7 @@ jobs: - name: Test if: matrix.target-platform != 'JS' # run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} - run: | - sbt project awsLambdaTests - sbt test + run: sbt awsLambdaTests/test # Temporarily call JS tests for each subproject explicitly as a workaround until # https://github.com/scala-js/scala-js/issues/4317 has a solution # - name: Test Scala.js From 3755542bc8d931140665e650662201b009c53750 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 12:07:59 +0200 Subject: [PATCH 15/35] try to install sam on ci (pull java11 runtime for sam) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6216a8e6db..9ddedd5e58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: wget -q https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation sudo ./sam-installation/install + docker pull -q amazon/aws-sam-cli-emulation-image-java11 sam --version - name: Compile run: sbt -v compile compileDocumentation From de401106235be1af9b7c577df8eb38e1f285cf57 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 12:21:46 +0200 Subject: [PATCH 16/35] try to install sam on ci (different image) --- .github/workflows/ci.yml | 2 +- build.sbt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ddedd5e58..d92a715128 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: wget -q https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation sudo ./sam-installation/install - docker pull -q amazon/aws-sam-cli-emulation-image-java11 + docker pull -q amazon/aws-sam-cli-emulation-image-java11:rapid-1.23.0 sam --version - name: Compile run: sbt -v compile compileDocumentation diff --git a/build.sbt b/build.sbt index 1b2eff915f..4370c0affa 100644 --- a/build.sbt +++ b/build.sbt @@ -903,7 +903,7 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ .value, Test / testOptions += Tests.Setup(() => { val log = sLog.value - val samReady = PollingUtils.poll(10.seconds, 1.second) { + val samReady = PollingUtils.poll(15.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) } if (!samReady) { From b8e56bdce695b28af1ef42774d8546f169ee8ff1 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 12:26:40 +0200 Subject: [PATCH 17/35] try to install sam on ci (different image 2) --- .github/workflows/ci.yml | 1 - build.sbt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d92a715128..6216a8e6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,6 @@ jobs: wget -q https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation sudo ./sam-installation/install - docker pull -q amazon/aws-sam-cli-emulation-image-java11:rapid-1.23.0 sam --version - name: Compile run: sbt -v compile compileDocumentation diff --git a/build.sbt b/build.sbt index 4370c0affa..d26c0e4c98 100644 --- a/build.sbt +++ b/build.sbt @@ -903,7 +903,7 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ .value, Test / testOptions += Tests.Setup(() => { val log = sLog.value - val samReady = PollingUtils.poll(15.seconds, 1.second) { + val samReady = PollingUtils.poll(30.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) } if (!samReady) { From 708972f224624d0fa9d369358c9da393aed518d7 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 12:37:29 +0200 Subject: [PATCH 18/35] revert commented ci stage --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6216a8e6db..6d5f1881f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,13 +41,12 @@ jobs: run: sbt -v compile compileDocumentation - name: Test if: matrix.target-platform != 'JS' -# run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} - run: sbt awsLambdaTests/test + run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} # Temporarily call JS tests for each subproject explicitly as a workaround until # https://github.com/scala-js/scala-js/issues/4317 has a solution -# - name: Test Scala.js -# if: matrix.target-platform == 'JS' -# run: sbt coreJS/test coreJS2_12/test catsJS/test catsJS2_12/test enumeratumJS/test enumeratumJS2_12/test refinedJS/test refinedJS2_12/test circeJsonJS/test circeJsonJS2_12/test playJsonJS/test playJsonJS2_12/test uPickleJsonJS/test uPickleJsonJS2_12/test jsoniterScalaJS/test jsoniterScalaJS2_12/test sttpClientJS/test sttpClientJS2_12/test + - name: Test Scala.js + if: matrix.target-platform == 'JS' + run: sbt coreJS/test coreJS2_12/test catsJS/test catsJS2_12/test enumeratumJS/test enumeratumJS2_12/test refinedJS/test refinedJS2_12/test circeJsonJS/test circeJsonJS2_12/test playJsonJS/test playJsonJS2_12/test uPickleJsonJS/test uPickleJsonJS2_12/test jsoniterScalaJS/test jsoniterScalaJS2_12/test sttpClientJS/test sttpClientJS2_12/test - name: Cleanup run: | rm -rf "$HOME/.ivy2/local" || true From 66f3c882795cdb944d0faeeee8fc92b0fd10b9ed Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 17 May 2021 13:55:51 +0200 Subject: [PATCH 19/35] fix assembly on 2_12 --- build.sbt | 5 +++-- .../aws/lambda/tests/AwsLambdaStubTestServer.scala | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/build.sbt b/build.sbt index d26c0e4c98..ff8cccab4b 100644 --- a/build.sbt +++ b/build.sbt @@ -894,8 +894,9 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ assembly / assemblyJarName := "tapir-aws-lambda-tests.jar", assembly / test := {}, // no tests before building jar assembly / assemblyMergeStrategy := { - case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first - case x => (assembly / assemblyMergeStrategy).value(x) + case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first + case _ @("scala/annotation/nowarn.class" | "scala/annotation/nowarn$.class") => MergeStrategy.first + case x => (assembly / assemblyMergeStrategy).value(x) }, Test / test := (Test / test) .dependsOn((Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate")) diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala index f9c043d7ad..4317d714d8 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala @@ -17,8 +17,6 @@ import sttp.tapir.serverless.aws.lambda._ import sttp.tapir.serverless.aws.lambda.tests.AwsLambdaStubTestServer._ import sttp.tapir.tests.Test -import java.util.Base64 - class AwsLambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { override def testServer[I, E, O]( @@ -101,6 +99,10 @@ object AwsLambdaStubTestServer { new String(response.body), new StatusCode(response.statusCode), "", - response.headers.map { case (n, v) => v.split(",").map(Header(n, _)) }.flatten.toSeq + response.headers + .map { case (n, v) => v.split(",").map(Header(n, _)) } + .flatten + .toSeq + .asInstanceOf[scala.collection.immutable.Seq[Header]] ) } From 7ccd2674c19e3396fbcf031ef6f201a1bf7fe3f1 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 20 May 2021 12:01:21 +0200 Subject: [PATCH 20/35] lambda deployment --- build.sbt | 14 ++ .../aws/terraform/AwsTerraformEncoders.scala | 144 ++++++++++++++++++ .../aws/terraform/AwsTerraformOptions.scala | 22 +++ .../EndpointsToTerraformTemplate.scala | 14 ++ .../aws/terraform/TerraformExample.scala | 22 +++ .../serverless/aws/terraform/model.scala | 4 + .../terraform/AwsTerraformEncodersTest.scala | 27 ++++ 7 files changed, 247 insertions(+) create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala create mode 100644 serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala diff --git a/build.sbt b/build.sbt index d26c0e4c98..773635b0cf 100644 --- a/build.sbt +++ b/build.sbt @@ -106,6 +106,7 @@ lazy val allAggregates = core.projectRefs ++ awsLambda.projectRefs ++ awsLambdaTests.projectRefs ++ awsSam.projectRefs ++ + awsTerraform.projectRefs ++ awsExamples.projectRefs ++ http4sClient.projectRefs ++ sttpClient.projectRefs ++ @@ -929,6 +930,19 @@ lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, tests % Test) +lazy val awsTerraform: ProjectMatrix = (projectMatrix in file("serverless/aws/terraform")) + .settings(commonJvmSettings) + .settings( + name := "tapir-aws-terraform", + libraryDependencies ++= Seq( + "io.circe" %% "circe-yaml" % Versions.circeYaml, + "io.circe" %% "circe-generic" % Versions.circe, + "io.circe" %% "circe-generic-extras" % Versions.circe + ) + ) + .jvmPlatform(scalaVersions = allScalaVersions) + .dependsOn(core, tests % Test) + lazy val runSamExample = taskKey[Unit]("runs aws lambda example on sam local") lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala new file mode 100644 index 0000000000..bd3a298ca2 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala @@ -0,0 +1,144 @@ +package sttp.tapir.serverless.aws.terraform + +import io.circe.{Encoder, Json} + +object AwsTerraformEncoders { + + private val lambdaFunctionResourceName = "lambda" + + implicit def encoderAwsLambdaFunction(implicit options: AwsTerraformOptions): Encoder[AwsLambdaFunction] = { + val encoder: Encoder[AwsLambdaFunction] = + Encoder.forProduct6("function_name", "role", "handler", "runtime", "s3_bucket", "s3_key")(l => + (l.functionName, "${aws_iam_role.lambda_exec.arn}", l.handler, l.runtime, l.s3Bucket, l.s3Key) + ) + + val provider = Json.fromFields(Seq("aws" -> Json.fromValues(Seq(Json.fromFields(Seq("region" -> Json.fromString(options.awsRegion))))))) + val terraform = Json.fromFields( + Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) + ) + + val iAmRole = Json.fromFields( + Seq( + "name" -> Json.fromString("lambda_exec_role"), + "assume_role_policy" -> Json.fromString(options.assumeRolePolicy) + ) + ) + + lambda => + Json.fromFields( + Seq( + "terraform" -> terraform, + "provider" -> provider, + "resource" -> Json.fromValues( + Seq( + resource("aws_lambda_function", "lambda", encoder(lambda)), + resource("aws_iam_role", "lambda_exec", iAmRole) + ) + ) + ) + ) + } + + implicit def encoderTerraformAwsApiGatewayMethods(implicit options: AwsTerraformOptions): Encoder[List[TerraformAwsApiGatewayMethod]] = { + val rest_api_id = s"aws_api_gateway_rest_api.${options.appName}.id" + val root_resource_id = s"aws_api_gateway_rest_api.${options.appName}.root_resource_id" + + val apiGatewayRestApi = Json.fromFields( + Seq( + "name" -> Json.fromString("ServerlessFunction"), + "description" -> Json.fromString("Terraform Serverless Application") + ) + ) + + val apiGatewayResource = Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(rest_api_id), + "resource_id" -> Json.fromString(root_resource_id), + "path_part" -> Json.fromString("{proxy+}") + ) + ) + + val apiGatewayMethodProxy = Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(rest_api_id), + "resource_id" -> Json.fromString("aws_api_gateway_resource.proxy.id"), + "http_method" -> Json.fromString("ANY"), + "authorization" -> Json.fromString("NONE") + ) + ) + + val apiGatewayIntegration = Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(rest_api_id), + "resource_id" -> Json.fromString("aws_api_gateway_method.proxy.resource_id"), + "http_method" -> Json.fromString("aws_api_gateway_method.proxy.http_method"), + "integration_http_method" -> Json.fromString("POST"), + "type" -> Json.fromString("AWS_PROXY"), + "uri" -> Json.fromString(s"aws_lambda_function.$lambdaFunctionResourceName.invoke_arn") + ) + ) + + val apiGatewayMethodProxyRoot = Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(rest_api_id), + "resource_id" -> Json.fromString(root_resource_id), + "http_method" -> Json.fromString("ANY"), + "authorization" -> Json.fromString("NONE") + ) + ) + + val apiGatewayIntegrationRoot = Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(rest_api_id), + "resource_id" -> Json.fromString("aws_api_gateway_method.proxy_root.resource_id"), + "http_method" -> Json.fromString("aws_api_gateway_method.proxy_root.http_method"), + "integration_http_method" -> Json.fromString("POST"), + "type" -> Json.fromString("AWS_PROXY"), + "uri" -> Json.fromString(s"aws_lambda_function.$lambdaFunctionResourceName.invoke_arn") + ) + ) + + val apiGatewayDeployment = Json.fromFields( + Seq( + "depends_on" -> Json.fromValues( + Seq( + Json.fromString("aws_api_gateway_integration.lambda"), + Json.fromString("aws_api_gateway_integration.lambda_root") + ) + ), + "rest_api_id" -> Json.fromString(rest_api_id), + "stage_name" -> Json.fromString("test") + ) + ) + + _ => + Json.fromFields( + Seq( + "resource" -> Json.fromValues( + Seq( + resource("aws_api_gateway_rest_api", s"${options.appName}", apiGatewayRestApi), + // resources below are responsible for forwarding any incoming request to lambda function + resource("aws_api_gateway_resource", "proxy", apiGatewayResource), + resource("aws_api_gateway_method", "proxy", apiGatewayMethodProxy), + resource("aws_api_gateway_integration", "lambda", apiGatewayIntegration), + resource("aws_api_gateway_method", "proxy_root", apiGatewayMethodProxyRoot), + resource("aws_api_gateway_integration", "lambda_root", apiGatewayIntegrationRoot), + // + resource("aws_api_gateway_deployment", s"${options.appName}", apiGatewayDeployment) + ) + ) + ) + ) + } + + private def resource[R](`type`: String, name: String, encoded: Json): Json = + Json.fromFields( + Seq( + `type` -> Json.fromFields( + Seq( + name -> encoded + ) + ) + ) + ) +} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala new file mode 100644 index 0000000000..d8bf1df841 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala @@ -0,0 +1,22 @@ +package sttp.tapir.serverless.aws.terraform + +import sttp.tapir.serverless.aws.terraform.AwsTerraformOptions.lambdaDefaultAssumeRolePolicy + +case class AwsTerraformOptions( + appName: String, + awsRegion: String, + assumeRolePolicy: String = lambdaDefaultAssumeRolePolicy +// source: FunctionSource, +// timeout: FiniteDuration = 10.seconds, +// memorySize: Int = 256 +) + +object AwsTerraformOptions { + // grants no policies for lambda function - it cannot access any other AWS services + private val lambdaDefaultAssumeRolePolicy = + "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Action\": \"sts:AssumeRole\",\n \"Principal\": {\n \"Service\": \"lambda.amazonaws.com\"\n },\n \"Effect\": \"Allow\",\n \"Sid\": \"\"\n }\n ]\n}" +} + +trait FunctionSource +case class ImageSource(imageUri: String) extends FunctionSource +case class CodeSource(runtime: String, codeUri: String, handler: String) extends FunctionSource diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala new file mode 100644 index 0000000000..553de2dab0 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala @@ -0,0 +1,14 @@ +package sttp.tapir.serverless.aws.terraform + +import io.circe.Json +import sttp.tapir.Endpoint + +private[terraform] object EndpointsToTerraformTemplate { + def apply(es: List[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): Unit = { + // first create lambda.tf + + + + // then api_gateway.tf + } +} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala new file mode 100644 index 0000000000..64b78766e8 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala @@ -0,0 +1,22 @@ +package sttp.tapir.serverless.aws.terraform + +import io.circe.Printer +import io.circe.syntax._ +import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ + +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.{Files, Paths} + +object TerraformExample extends App { + + val lambda = AwsLambdaFunction("ServerlessExample", "main.handler", "nodejs10.x", "terraform-example-kubinio", "v1.0.0/example.zip") + val methods = List.empty[TerraformAwsApiGatewayMethod] + + implicit val options: AwsTerraformOptions = AwsTerraformOptions("Tapir", "eu-central-1") + + val lambdaJson = Printer.spaces2.print(lambda.asJson) + val apiGatewayJson = Printer.spaces2.print(methods.asJson) + + Files.write(Paths.get("serverless/aws/terraform/example/lambda.tf.json"), lambdaJson.getBytes(UTF_8)) +// Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.json"), apiGatewayJson.getBytes(UTF_8)) +} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala new file mode 100644 index 0000000000..aedd24c7b6 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -0,0 +1,4 @@ +package sttp.tapir.serverless.aws.terraform + +case class AwsLambdaFunction(functionName: String, handler: String, runtime: String, s3Bucket: String, s3Key: String) +case class TerraformAwsApiGatewayMethod(httpMethod: String, requestParameters: Map[String, Boolean]) diff --git a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala new file mode 100644 index 0000000000..bddec0d47a --- /dev/null +++ b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala @@ -0,0 +1,27 @@ +package sttp.tapir.serverless.aws.terraform + +import io.circe.Printer +import io.circe.syntax._ +import org.scalatest.funsuite.AnyFunSuite +import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ + +class AwsTerraformEncodersTest extends AnyFunSuite { + + test("hello") { + val lambda = AwsLambdaFunction("hehe", "hehe", "hehe", "hehe", "hehe") + implicit val options: AwsTerraformOptions = AwsTerraformOptions("hehe", "hehe") + + val x = lambda.asJson + + val s = Printer.noSpaces.print(x) + + println(s) + } + + test("hello 2") { + val methods = List.empty[TerraformAwsApiGatewayMethod] + implicit val options: AwsTerraformOptions = AwsTerraformOptions("tapir", "eu-west-1") + + println(Printer.noSpaces.print(methods.asJson)) + } +} From 5fefe75423b8be5435786b1be604bc960708d5da Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 20 May 2021 13:55:22 +0200 Subject: [PATCH 21/35] api gateway deployment --- .../aws/terraform/AwsTerraformEncoders.scala | 94 +++++++++++++------ .../aws/terraform/AwsTerraformOptions.scala | 18 ++-- .../aws/terraform/TerraformExample.scala | 14 ++- .../serverless/aws/terraform/model.scala | 3 +- .../terraform/AwsTerraformEncodersTest.scala | 16 ---- 5 files changed, 84 insertions(+), 61 deletions(-) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala index bd3a298ca2..802528c911 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala @@ -6,17 +6,37 @@ object AwsTerraformEncoders { private val lambdaFunctionResourceName = "lambda" - implicit def encoderAwsLambdaFunction(implicit options: AwsTerraformOptions): Encoder[AwsLambdaFunction] = { - val encoder: Encoder[AwsLambdaFunction] = - Encoder.forProduct6("function_name", "role", "handler", "runtime", "s3_bucket", "s3_key")(l => - (l.functionName, "${aws_iam_role.lambda_exec.arn}", l.handler, l.runtime, l.s3Bucket, l.s3Key) - ) + implicit def encoderAwsTerraformOptions(implicit options: AwsTerraformOptions): Encoder[AwsTerraformOptions] = { val provider = Json.fromFields(Seq("aws" -> Json.fromValues(Seq(Json.fromFields(Seq("region" -> Json.fromString(options.awsRegion))))))) val terraform = Json.fromFields( Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) ) + val functionSource: Seq[(String, Json)] = options.lambdaSource match { + case s3: S3Source => + Seq( + "s3_bucket" -> Json.fromString(s3.bucket), + "s3_key" -> Json.fromString(s3.key), + "runtime" -> Json.fromString(s3.runtime), + "handler" -> Json.fromString(s3.handler) + ) + case image: ImageSource => Seq("image_uri" -> Json.fromString(image.imageUri)) + case code: CodeSource => + Seq( + "filename" -> Json.fromString(code.fileName), + "runtime" -> Json.fromString(code.runtime), + "handler" -> Json.fromString(code.handler) + ) + } + + val lambdaFunction = Json.fromFields( + Seq( + "function_name" -> Json.fromString(options.lambdaFunctionName), + "role" -> Json.fromString("${aws_iam_role.lambda_exec.arn}") + ) ++ functionSource + ) + val iAmRole = Json.fromFields( Seq( "name" -> Json.fromString("lambda_exec_role"), @@ -24,24 +44,35 @@ object AwsTerraformEncoders { ) ) - lambda => + val apiGatewayPermission = Json.fromFields( + Seq( + "statement_id" -> Json.fromString("AllowAPIGatewayInvoke"), + "action" -> Json.fromString("lambda:InvokeFunction"), + "function_name" -> Json.fromString(s"$${aws_lambda_function.$lambdaFunctionResourceName.function_name}"), + "principal" -> Json.fromString("apigateway.amazonaws.com"), + "source_arn" -> Json.fromString(s"$${aws_api_gateway_rest_api.${options.apiGatewayName}.execution_arn}/*/*") + ) + ) + + _ => Json.fromFields( Seq( "terraform" -> terraform, "provider" -> provider, "resource" -> Json.fromValues( Seq( - resource("aws_lambda_function", "lambda", encoder(lambda)), - resource("aws_iam_role", "lambda_exec", iAmRole) + resource("aws_lambda_function", lambdaFunctionResourceName, lambdaFunction), + resource("aws_iam_role", "lambda_exec", iAmRole), + resource("aws_lambda_permission", "api_gateway_permission", apiGatewayPermission) ) ) ) ) } - implicit def encoderTerraformAwsApiGatewayMethods(implicit options: AwsTerraformOptions): Encoder[List[TerraformAwsApiGatewayMethod]] = { - val rest_api_id = s"aws_api_gateway_rest_api.${options.appName}.id" - val root_resource_id = s"aws_api_gateway_rest_api.${options.appName}.root_resource_id" + implicit def encoderAwsTerraformApiGatewayMethods(implicit options: AwsTerraformOptions): Encoder[List[AwsTerraformApiGatewayMethod]] = { + val rest_api_id = s"$${aws_api_gateway_rest_api.${options.apiGatewayName}.id}" + val root_resource_id = s"$${aws_api_gateway_rest_api.${options.apiGatewayName}.root_resource_id}" val apiGatewayRestApi = Json.fromFields( Seq( @@ -53,7 +84,7 @@ object AwsTerraformEncoders { val apiGatewayResource = Json.fromFields( Seq( "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString(root_resource_id), + "parent_id" -> Json.fromString(root_resource_id), "path_part" -> Json.fromString("{proxy+}") ) ) @@ -61,7 +92,7 @@ object AwsTerraformEncoders { val apiGatewayMethodProxy = Json.fromFields( Seq( "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString("aws_api_gateway_resource.proxy.id"), + "resource_id" -> Json.fromString("${aws_api_gateway_resource.proxy.id}"), "http_method" -> Json.fromString("ANY"), "authorization" -> Json.fromString("NONE") ) @@ -70,11 +101,11 @@ object AwsTerraformEncoders { val apiGatewayIntegration = Json.fromFields( Seq( "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString("aws_api_gateway_method.proxy.resource_id"), - "http_method" -> Json.fromString("aws_api_gateway_method.proxy.http_method"), + "resource_id" -> Json.fromString("${aws_api_gateway_method.proxy.resource_id}"), + "http_method" -> Json.fromString("${aws_api_gateway_method.proxy.http_method}"), "integration_http_method" -> Json.fromString("POST"), "type" -> Json.fromString("AWS_PROXY"), - "uri" -> Json.fromString(s"aws_lambda_function.$lambdaFunctionResourceName.invoke_arn") + "uri" -> Json.fromString(s"$${aws_lambda_function.$lambdaFunctionResourceName.invoke_arn}") ) ) @@ -90,11 +121,11 @@ object AwsTerraformEncoders { val apiGatewayIntegrationRoot = Json.fromFields( Seq( "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString("aws_api_gateway_method.proxy_root.resource_id"), - "http_method" -> Json.fromString("aws_api_gateway_method.proxy_root.http_method"), + "resource_id" -> Json.fromString("${aws_api_gateway_method.proxy_root.resource_id}"), + "http_method" -> Json.fromString("${aws_api_gateway_method.proxy_root.http_method}"), "integration_http_method" -> Json.fromString("POST"), "type" -> Json.fromString("AWS_PROXY"), - "uri" -> Json.fromString(s"aws_lambda_function.$lambdaFunctionResourceName.invoke_arn") + "uri" -> Json.fromString(s"$${aws_lambda_function.$lambdaFunctionResourceName.invoke_arn}") ) ) @@ -111,12 +142,20 @@ object AwsTerraformEncoders { ) ) + val output = Json.fromFields( + Seq( + "base_url" -> Json.fromFields( + Seq("value" -> Json.fromString(s"$${aws_api_gateway_deployment.${options.apiGatewayName}.invoke_url}")) + ) + ) + ) + _ => Json.fromFields( Seq( "resource" -> Json.fromValues( Seq( - resource("aws_api_gateway_rest_api", s"${options.appName}", apiGatewayRestApi), + resource("aws_api_gateway_rest_api", s"${options.apiGatewayName}", apiGatewayRestApi), // resources below are responsible for forwarding any incoming request to lambda function resource("aws_api_gateway_resource", "proxy", apiGatewayResource), resource("aws_api_gateway_method", "proxy", apiGatewayMethodProxy), @@ -124,21 +163,14 @@ object AwsTerraformEncoders { resource("aws_api_gateway_method", "proxy_root", apiGatewayMethodProxyRoot), resource("aws_api_gateway_integration", "lambda_root", apiGatewayIntegrationRoot), // - resource("aws_api_gateway_deployment", s"${options.appName}", apiGatewayDeployment) + resource("aws_api_gateway_deployment", s"${options.apiGatewayName}", apiGatewayDeployment) ) - ) + ), + "output" -> output ) ) } private def resource[R](`type`: String, name: String, encoded: Json): Json = - Json.fromFields( - Seq( - `type` -> Json.fromFields( - Seq( - name -> encoded - ) - ) - ) - ) + Json.fromFields(Seq(`type` -> Json.fromFields(Seq(name -> encoded)))) } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala index d8bf1df841..cb14fb73ca 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala @@ -2,13 +2,16 @@ package sttp.tapir.serverless.aws.terraform import sttp.tapir.serverless.aws.terraform.AwsTerraformOptions.lambdaDefaultAssumeRolePolicy +import scala.concurrent.duration.{DurationInt, FiniteDuration} + case class AwsTerraformOptions( - appName: String, awsRegion: String, - assumeRolePolicy: String = lambdaDefaultAssumeRolePolicy -// source: FunctionSource, -// timeout: FiniteDuration = 10.seconds, -// memorySize: Int = 256 + lambdaFunctionName: String, + apiGatewayName: String, + assumeRolePolicy: String = lambdaDefaultAssumeRolePolicy, + lambdaSource: FunctionSource, + timeout: FiniteDuration = 10.seconds, + memorySize: Int = 256 ) object AwsTerraformOptions { @@ -17,6 +20,7 @@ object AwsTerraformOptions { "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Action\": \"sts:AssumeRole\",\n \"Principal\": {\n \"Service\": \"lambda.amazonaws.com\"\n },\n \"Effect\": \"Allow\",\n \"Sid\": \"\"\n }\n ]\n}" } -trait FunctionSource +sealed trait FunctionSource +case class S3Source(bucket: String, key: String, runtime: String, handler: String) extends FunctionSource case class ImageSource(imageUri: String) extends FunctionSource -case class CodeSource(runtime: String, codeUri: String, handler: String) extends FunctionSource +case class CodeSource(fileName: String, runtime: String, handler: String) extends FunctionSource diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala index 64b78766e8..777bc0077c 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala @@ -9,14 +9,18 @@ import java.nio.file.{Files, Paths} object TerraformExample extends App { - val lambda = AwsLambdaFunction("ServerlessExample", "main.handler", "nodejs10.x", "terraform-example-kubinio", "v1.0.0/example.zip") - val methods = List.empty[TerraformAwsApiGatewayMethod] + val methods = List.empty[AwsTerraformApiGatewayMethod] - implicit val options: AwsTerraformOptions = AwsTerraformOptions("Tapir", "eu-central-1") + implicit val options: AwsTerraformOptions = AwsTerraformOptions( + awsRegion = "eu-central-1", + lambdaFunctionName = "Tapir", + apiGatewayName = "TapirApiGateway", + lambdaSource = S3Source("terraform-example-kubinio", "v1.0.0/example.zip", "nodejs10.x", "main.handler") + ) - val lambdaJson = Printer.spaces2.print(lambda.asJson) + val lambdaJson = Printer.spaces2.print(options.asJson) val apiGatewayJson = Printer.spaces2.print(methods.asJson) Files.write(Paths.get("serverless/aws/terraform/example/lambda.tf.json"), lambdaJson.getBytes(UTF_8)) -// Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.json"), apiGatewayJson.getBytes(UTF_8)) + Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.tf.json"), apiGatewayJson.getBytes(UTF_8)) } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala index aedd24c7b6..b557016b2d 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -1,4 +1,3 @@ package sttp.tapir.serverless.aws.terraform -case class AwsLambdaFunction(functionName: String, handler: String, runtime: String, s3Bucket: String, s3Key: String) -case class TerraformAwsApiGatewayMethod(httpMethod: String, requestParameters: Map[String, Boolean]) +case class AwsTerraformApiGatewayMethod(httpMethod: String, requestParameters: Map[String, Boolean]) diff --git a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala index bddec0d47a..7206f86c9d 100644 --- a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala +++ b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala @@ -7,21 +7,5 @@ import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ class AwsTerraformEncodersTest extends AnyFunSuite { - test("hello") { - val lambda = AwsLambdaFunction("hehe", "hehe", "hehe", "hehe", "hehe") - implicit val options: AwsTerraformOptions = AwsTerraformOptions("hehe", "hehe") - val x = lambda.asJson - - val s = Printer.noSpaces.print(x) - - println(s) - } - - test("hello 2") { - val methods = List.empty[TerraformAwsApiGatewayMethod] - implicit val options: AwsTerraformOptions = AwsTerraformOptions("tapir", "eu-west-1") - - println(Printer.noSpaces.print(methods.asJson)) - } } From f5746820b4b64311e8633ef6ebb112396e05571a Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 24 May 2021 21:29:20 +0200 Subject: [PATCH 22/35] terraform encoders --- build.sbt | 7 +- .../aws/examples/LambdaApiExample.scala | 26 +-- .../aws/examples/SamTemplateExample.scala | 30 +++ .../aws/examples/TerraformConfigExample.scala | 39 ++++ .../aws/sam/EndpointsToSamTemplate.scala | 14 +- .../aws/terraform/ApiResourceTree.scala | 45 +++++ .../AwsTerraformApiGatewayMethod.scala | 10 + .../aws/terraform/AwsTerraformEncoders.scala | 186 +++++------------- .../terraform/AwsTerraformInterpreter.scala | 26 +++ .../aws/terraform/AwsTerraformOptions.scala | 24 ++- .../EndpointsToTerraformConfig.scala | 72 +++++++ .../EndpointsToTerraformTemplate.scala | 14 -- .../aws/terraform/TerraformExample.scala | 11 +- .../aws/terraform/TerraformResource.scala | 162 +++++++++++++++ .../serverless/aws/terraform/model.scala | 3 - 15 files changed, 475 insertions(+), 194 deletions(-) create mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala create mode 100644 serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformInterpreter.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala delete mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala delete mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala diff --git a/build.sbt b/build.sbt index 773635b0cf..2996010c67 100644 --- a/build.sbt +++ b/build.sbt @@ -937,7 +937,8 @@ lazy val awsTerraform: ProjectMatrix = (projectMatrix in file("serverless/aws/te libraryDependencies ++= Seq( "io.circe" %% "circe-yaml" % Versions.circeYaml, "io.circe" %% "circe-generic" % Versions.circe, - "io.circe" %% "circe-generic-extras" % Versions.circe + "io.circe" %% "circe-literal" % Versions.circe, + "org.typelevel" %% "jawn-parser" % "1.0.0" ) ) .jvmPlatform(scalaVersions = allScalaVersions) @@ -955,7 +956,7 @@ lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/exa assembly / assemblyJarName := "tapir-aws-examples.jar", runSamExample := { val log = sLog.value - (Compile / runMain).toTask(" sttp.tapir.serverless.aws.examples.LambdaApiExampleSamTemplate").value + (Compile / runMain).toTask(" sttp.tapir.serverless.aws.examples.SamTemplateExample$").value val samReady = PollingUtils.poll(20.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/api/hello")) } @@ -971,7 +972,7 @@ lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/exa } ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(awsLambda, awsSam) + .dependsOn(awsLambda, awsSam, awsTerraform) // client diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala index cbabe13d5d..6120b9cb36 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala @@ -10,13 +10,11 @@ import io.circe.syntax._ import sttp.model.StatusCode import sttp.tapir._ import sttp.tapir.server.ServerEndpoint -import sttp.tapir.serverless.aws.examples.LambdaApiExample.{helloEndpoint, route} +import sttp.tapir.serverless.aws.examples.LambdaApiExample.route import sttp.tapir.serverless.aws.lambda._ -import sttp.tapir.serverless.aws.sam.{AwsSamInterpreter, AwsSamOptions, CodeSource} import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} import java.nio.charset.StandardCharsets.UTF_8 -import java.nio.file.{Files, Paths} /** Example assumes that you have `sam local` installed on your OS. Installation is described here: * https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html @@ -56,25 +54,3 @@ object LambdaApiExample { val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) } - -/** Before running the actual example we need to interpret our api as SAM template */ -object LambdaApiExampleSamTemplate extends App { - - val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString - - implicit val samOptions: AwsSamOptions = AwsSamOptions( - "PersonsApi", - source = - /** Specifying a fat jar build from example sources */ - CodeSource( - runtime = "java11", - codeUri = jarPath, - handler = "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest" - ) - ) - - val templateYaml = AwsSamInterpreter.toSamTemplate(helloEndpoint).toYaml - - /** Write template to file, it's required to run the example using sam local */ - Files.write(Paths.get("template.yaml"), templateYaml.getBytes(UTF_8)) -} diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala new file mode 100644 index 0000000000..9e800131ee --- /dev/null +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala @@ -0,0 +1,30 @@ +package sttp.tapir.serverless.aws.examples + +import sttp.tapir.serverless.aws.examples.LambdaApiExample.helloEndpoint +import sttp.tapir.serverless.aws.sam.{AwsSamInterpreter, AwsSamOptions, CodeSource} + +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.{Files, Paths} + +/** Before running the actual example we need to interpret our api as SAM template */ +object SamTemplateExample extends App { + + val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString + + implicit val samOptions: AwsSamOptions = AwsSamOptions( + "PersonsApi", + source = + /** Specifying a fat jar build from example sources */ + CodeSource( + runtime = "java11", + codeUri = jarPath, + handler = "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest" + ) + ) + + val templateYaml = AwsSamInterpreter.toSamTemplate(helloEndpoint).toYaml + + /** Write template to file, it's required to run the example using sam local */ + Files.write(Paths.get("template.yaml"), templateYaml.getBytes(UTF_8)) + +} diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala new file mode 100644 index 0000000000..0a2177d87f --- /dev/null +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala @@ -0,0 +1,39 @@ +package sttp.tapir.serverless.aws.examples + +import io.circe.Printer +import io.circe.syntax._ +import sttp.tapir.serverless.aws.examples.LambdaApiExample.helloEndpoint +import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ +import sttp.tapir.serverless.aws.terraform.{AwsTerraformApiGateway, AwsTerraformInterpreter, AwsTerraformOptions, S3Source} + +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.{Files, Paths} + +/** Before running the actual example we need to interpret our api as Terraform resources */ +object TerraformConfigExample extends App { + + val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString + + implicit val terraformOptions: AwsTerraformOptions = AwsTerraformOptions( + "eu-central-1", + functionName = "PersonsFunction", + apiGatewayName = "PersonsApiGateway", + functionSource = S3Source( + "terraform-example-kubinio", + "v1.0.0/tapir-aws-examples.jar", + "java11", + "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest" + ) + ) + + /** lambda config is created using `AwsTerraformOptions` */ + val lambdaConfig: String = Printer.spaces2.print(terraformOptions.asJson) + + /** api gateway config is created using `Endpoint`s */ + val apiGateway: AwsTerraformApiGateway = AwsTerraformInterpreter.toTerraformConfig(helloEndpoint) + + val apiGatewayConfig = Printer.spaces2.print(apiGateway.asJson) + + Files.write(Paths.get("serverless/aws/terraform/example/lambda.tf.json"), lambdaConfig.getBytes(UTF_8)) + Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.tf.json"), apiGatewayConfig.getBytes(UTF_8)) +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala index b256cbb6bb..cae49d7632 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/EndpointsToSamTemplate.scala @@ -49,15 +49,11 @@ private[sam] object EndpointsToSamTemplate { private def endpointNameMethodAndPath(e: Endpoint[_, _, _, _]): (String, Option[Method], String) = { val pathComponents = e.input .asVectorOfBasicInputs() - .collect { - case EndpointInput.PathCapture(name, _, _) => Left(name) - case EndpointInput.FixedPath(s, _, _) => Right(s) - } - .foldLeft((Vector.empty[Either[String, String]], 0)) { case ((acc, c), component) => - component match { - case Left(None) => (acc :+ Left(s"param$c"), c + 1) - case Left(Some(p)) => (acc :+ Left(p), c) - case Right(p) => (acc :+ Right(p), c) + .foldLeft((Vector.empty[Either[String, String]], 0)) { case ((acc, c), input) => + input match { + case EndpointInput.PathCapture(name, _, _) => (acc :+ Left(name.getOrElse(s"param$c")), if (name.isEmpty) c + 1 else c) + case EndpointInput.FixedPath(p, _, _) => (acc :+ Right(p), c) + case _ => (acc, c) } } ._1 diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala new file mode 100644 index 0000000000..3c0a8ae6f0 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala @@ -0,0 +1,45 @@ +package sttp.tapir.serverless.aws.terraform + +import sttp.tapir.serverless.aws.terraform.EndpointsToTerraformConfig.IdEndpointInput +import sttp.tapir.{Codec, EndpointIO, EndpointInput} + +/** Creates the tree representation of routes for given endpoints represented as basic inputs */ +private[terraform] object ApiResourceTree { + val RootPath = "/" + val RootPathComponent: PathComponent = + PathComponent("root", Left(EndpointInput.FixedPath(RootPath, Codec.idPlain(), EndpointIO.Info.empty))) + + private val pathMatches: (PathComponent, PathComponent) => Boolean = (a, b) => { + (a.component, b.component) match { + case (Left(fp1), Left(fp2)) => fp1.s == fp2.s + case (Right(pc1), Right(pc2)) => pc1.name == pc2.name + case _ => false + } + } + + def apply(basicInputs: Seq[Vector[IdEndpointInput]]): ResourceTree = { + + val endpointPathComponents: Seq[Seq[PathComponent]] = basicInputs.map(_.collect { + case (id, fp @ EndpointInput.FixedPath(_, _, _)) => PathComponent(id, Left(fp)) + case (id, pc @ EndpointInput.PathCapture(_, _, _)) => PathComponent(id, Right(pc)) + }) + + def getChildren(level: Int, path: PathComponent): ResourceTree = { + val children: Seq[PathComponent] = endpointPathComponents + .filter(pcs => pcs.lift(level).isDefined && pathMatches(pcs(level - 1), path)) + .map(_(level)) + .groupBy(_.name) + .flatMap { case (_, pcs) => pcs.headOption } + .toSeq + if (children.isEmpty) ResourceTree(path, Seq.empty) + else ResourceTree(path, children.map(getChildren(level + 1, _))) + } + + val paths0: Seq[PathComponent] = + endpointPathComponents.flatMap(_.headOption).groupBy(_.name).flatMap { case (_, pcs) => pcs.headOption }.toSeq + + ResourceTree(RootPathComponent, paths0.map(getChildren(1, _))) + } +} + +case class ResourceTree(pathComponent: PathComponent, children: Seq[ResourceTree]) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala new file mode 100644 index 0000000000..a8eea05ec9 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala @@ -0,0 +1,10 @@ +package sttp.tapir.serverless.aws.terraform + +case class AwsTerraformApiGateway(resourceTree: ResourceTree, methods: Seq[AwsTerraformApiGatewayMethod]) +case class AwsTerraformApiGatewayMethod( + name: String, + path: String, + httpMethod: String, + pathComponents: Seq[PathComponent], + requestParameters: Seq[(String, Boolean)] +) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala index 802528c911..b8c5f910df 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala @@ -1,11 +1,10 @@ package sttp.tapir.serverless.aws.terraform import io.circe.{Encoder, Json} +import sttp.tapir.serverless.aws.terraform.ApiResourceTree.RootPath object AwsTerraformEncoders { - private val lambdaFunctionResourceName = "lambda" - implicit def encoderAwsTerraformOptions(implicit options: AwsTerraformOptions): Encoder[AwsTerraformOptions] = { val provider = Json.fromFields(Seq("aws" -> Json.fromValues(Seq(Json.fromFields(Seq("region" -> Json.fromString(options.awsRegion))))))) @@ -13,46 +12,9 @@ object AwsTerraformEncoders { Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) ) - val functionSource: Seq[(String, Json)] = options.lambdaSource match { - case s3: S3Source => - Seq( - "s3_bucket" -> Json.fromString(s3.bucket), - "s3_key" -> Json.fromString(s3.key), - "runtime" -> Json.fromString(s3.runtime), - "handler" -> Json.fromString(s3.handler) - ) - case image: ImageSource => Seq("image_uri" -> Json.fromString(image.imageUri)) - case code: CodeSource => - Seq( - "filename" -> Json.fromString(code.fileName), - "runtime" -> Json.fromString(code.runtime), - "handler" -> Json.fromString(code.handler) - ) - } - - val lambdaFunction = Json.fromFields( - Seq( - "function_name" -> Json.fromString(options.lambdaFunctionName), - "role" -> Json.fromString("${aws_iam_role.lambda_exec.arn}") - ) ++ functionSource - ) - - val iAmRole = Json.fromFields( - Seq( - "name" -> Json.fromString("lambda_exec_role"), - "assume_role_policy" -> Json.fromString(options.assumeRolePolicy) - ) - ) - - val apiGatewayPermission = Json.fromFields( - Seq( - "statement_id" -> Json.fromString("AllowAPIGatewayInvoke"), - "action" -> Json.fromString("lambda:InvokeFunction"), - "function_name" -> Json.fromString(s"$${aws_lambda_function.$lambdaFunctionResourceName.function_name}"), - "principal" -> Json.fromString("apigateway.amazonaws.com"), - "source_arn" -> Json.fromString(s"$${aws_api_gateway_rest_api.${options.apiGatewayName}.execution_arn}/*/*") - ) - ) + val lambdaFunction = AwsLambdaFunction(options.functionName, options.timeout, options.memorySize, options.functionSource) + val iAmRole = AwsIamRole(options.assumeRolePolicy.noSpaces) + val permission = AwsLambdaPermission(options.apiGatewayName) _ => Json.fromFields( @@ -61,116 +23,74 @@ object AwsTerraformEncoders { "provider" -> provider, "resource" -> Json.fromValues( Seq( - resource("aws_lambda_function", lambdaFunctionResourceName, lambdaFunction), - resource("aws_iam_role", "lambda_exec", iAmRole), - resource("aws_lambda_permission", "api_gateway_permission", apiGatewayPermission) + lambdaFunction.json(), + iAmRole.json(), + permission.json() ) ) ) ) } - implicit def encoderAwsTerraformApiGatewayMethods(implicit options: AwsTerraformOptions): Encoder[List[AwsTerraformApiGatewayMethod]] = { - val rest_api_id = s"$${aws_api_gateway_rest_api.${options.apiGatewayName}.id}" - val root_resource_id = s"$${aws_api_gateway_rest_api.${options.apiGatewayName}.root_resource_id}" - - val apiGatewayRestApi = Json.fromFields( - Seq( - "name" -> Json.fromString("ServerlessFunction"), - "description" -> Json.fromString("Terraform Serverless Application") - ) - ) - - val apiGatewayResource = Json.fromFields( - Seq( - "rest_api_id" -> Json.fromString(rest_api_id), - "parent_id" -> Json.fromString(root_resource_id), - "path_part" -> Json.fromString("{proxy+}") - ) - ) - - val apiGatewayMethodProxy = Json.fromFields( - Seq( - "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString("${aws_api_gateway_resource.proxy.id}"), - "http_method" -> Json.fromString("ANY"), - "authorization" -> Json.fromString("NONE") - ) - ) - - val apiGatewayIntegration = Json.fromFields( - Seq( - "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString("${aws_api_gateway_method.proxy.resource_id}"), - "http_method" -> Json.fromString("${aws_api_gateway_method.proxy.http_method}"), - "integration_http_method" -> Json.fromString("POST"), - "type" -> Json.fromString("AWS_PROXY"), - "uri" -> Json.fromString(s"$${aws_lambda_function.$lambdaFunctionResourceName.invoke_arn}") - ) - ) - - val apiGatewayMethodProxyRoot = Json.fromFields( - Seq( - "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString(root_resource_id), - "http_method" -> Json.fromString("ANY"), - "authorization" -> Json.fromString("NONE") - ) - ) + implicit def encoderAwsTerraformApiGateway(implicit options: AwsTerraformOptions): Encoder[AwsTerraformApiGateway] = + gateway => { + + def toTerraformResources(parent: String, resource: ResourceTree): Seq[TerraformResource] = { + val name = resource.pathComponent.name + val resourceName = if (parent == RootPath) name else s"$parent-$name" + val apiGatewayResource = AwsApiGatewayResource( + resourceName, + options.apiGatewayName, + if (parent == RootPath) options.apiGatewayName else parent, + resource.pathComponent.component.map(_ => s"{$name}").getOrElse(name) + ) - val apiGatewayIntegrationRoot = Json.fromFields( - Seq( - "rest_api_id" -> Json.fromString(rest_api_id), - "resource_id" -> Json.fromString("${aws_api_gateway_method.proxy_root.resource_id}"), - "http_method" -> Json.fromString("${aws_api_gateway_method.proxy_root.http_method}"), - "integration_http_method" -> Json.fromString("POST"), - "type" -> Json.fromString("AWS_PROXY"), - "uri" -> Json.fromString(s"$${aws_lambda_function.$lambdaFunctionResourceName.invoke_arn}") + if (resource.children.isEmpty) { + // reached the leaf - last path component of single endpoint + // integration resource has to be linked to last path component resource + val methodIntegrationResources = + gateway.methods + .find(_.pathComponents.lastOption.exists(_.id == resource.pathComponent.id)) + .map { method => + Seq( + AwsApiGatewayMethod(method.name, options.apiGatewayName, resourceName, method.httpMethod, method.requestParameters), + AwsApiGatewayIntegration(method.name, options.apiGatewayName) + ) + } + .getOrElse(Seq.empty) + + Seq(apiGatewayResource) ++ methodIntegrationResources + + } else Seq(apiGatewayResource) ++ resource.children.flatMap(child => toTerraformResources(parent = resourceName, child)) + } + + val pathComponentResources: Seq[TerraformResource] = + gateway.resourceTree.children.flatMap(child => toTerraformResources(RootPath, child)) + + val apiGatewayDeployment = AwsApiGatewayDeployment( + options.apiGatewayName, + options.apiGatewayName, + pathComponentResources.collect { case i @ AwsApiGatewayIntegration(_, _) => i } ) - ) - val apiGatewayDeployment = Json.fromFields( - Seq( - "depends_on" -> Json.fromValues( - Seq( - Json.fromString("aws_api_gateway_integration.lambda"), - Json.fromString("aws_api_gateway_integration.lambda_root") + val output = Json.fromFields( + Seq( + "base_url" -> Json.fromFields( + Seq("value" -> Json.fromString(s"$${aws_api_gateway_deployment.${options.apiGatewayName}.invoke_url}")) ) - ), - "rest_api_id" -> Json.fromString(rest_api_id), - "stage_name" -> Json.fromString("test") - ) - ) - - val output = Json.fromFields( - Seq( - "base_url" -> Json.fromFields( - Seq("value" -> Json.fromString(s"$${aws_api_gateway_deployment.${options.apiGatewayName}.invoke_url}")) ) ) - ) - _ => Json.fromFields( Seq( "resource" -> Json.fromValues( Seq( - resource("aws_api_gateway_rest_api", s"${options.apiGatewayName}", apiGatewayRestApi), - // resources below are responsible for forwarding any incoming request to lambda function - resource("aws_api_gateway_resource", "proxy", apiGatewayResource), - resource("aws_api_gateway_method", "proxy", apiGatewayMethodProxy), - resource("aws_api_gateway_integration", "lambda", apiGatewayIntegration), - resource("aws_api_gateway_method", "proxy_root", apiGatewayMethodProxyRoot), - resource("aws_api_gateway_integration", "lambda_root", apiGatewayIntegrationRoot), - // - resource("aws_api_gateway_deployment", s"${options.apiGatewayName}", apiGatewayDeployment) - ) + AwsApiGatewayRestApi(options.apiGatewayName, "description").json(), + apiGatewayDeployment.json() + ) ++ pathComponentResources.map(_.json()) ), "output" -> output ) ) - } - - private def resource[R](`type`: String, name: String, encoded: Json): Json = - Json.fromFields(Seq(`type` -> Json.fromFields(Seq(name -> encoded)))) + } } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformInterpreter.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformInterpreter.scala new file mode 100644 index 0000000000..81b77f6d72 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformInterpreter.scala @@ -0,0 +1,26 @@ +package sttp.tapir.serverless.aws.terraform + +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint + +trait AwsTerraformInterpreter { + def toTerraformConfig[I, E, O, S](e: Endpoint[I, E, O, S])(implicit options: AwsTerraformOptions): AwsTerraformApiGateway = + EndpointsToTerraformConfig(List(e)) + + def toTerraformConfig(es: Iterable[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): AwsTerraformApiGateway = + EndpointsToTerraformConfig(es.toList) + + def toTerraformConfig[I, E, O, S, F[_]](se: ServerEndpoint[I, E, O, S, F])(implicit + options: AwsTerraformOptions + ): AwsTerraformApiGateway = + EndpointsToTerraformConfig( + List(se.endpoint) + ) + + def serverEndpointsToTerraformConfig[F[_]](ses: Iterable[ServerEndpoint[_, _, _, _, F]])(implicit + options: AwsTerraformOptions + ): AwsTerraformApiGateway = + EndpointsToTerraformConfig(ses.map(_.endpoint).toList) +} + +object AwsTerraformInterpreter extends AwsTerraformInterpreter diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala index cb14fb73ca..701e412e94 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala @@ -1,15 +1,17 @@ package sttp.tapir.serverless.aws.terraform +import io.circe.Json +import io.circe.literal._ import sttp.tapir.serverless.aws.terraform.AwsTerraformOptions.lambdaDefaultAssumeRolePolicy import scala.concurrent.duration.{DurationInt, FiniteDuration} case class AwsTerraformOptions( awsRegion: String, - lambdaFunctionName: String, + functionName: String, apiGatewayName: String, - assumeRolePolicy: String = lambdaDefaultAssumeRolePolicy, - lambdaSource: FunctionSource, + assumeRolePolicy: Json = lambdaDefaultAssumeRolePolicy, + functionSource: FunctionSource, timeout: FiniteDuration = 10.seconds, memorySize: Int = 256 ) @@ -17,7 +19,21 @@ case class AwsTerraformOptions( object AwsTerraformOptions { // grants no policies for lambda function - it cannot access any other AWS services private val lambdaDefaultAssumeRolePolicy = - "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Action\": \"sts:AssumeRole\",\n \"Principal\": {\n \"Service\": \"lambda.amazonaws.com\"\n },\n \"Effect\": \"Allow\",\n \"Sid\": \"\"\n }\n ]\n}" + json""" + { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Effect": "Allow", + "Sid": "" + } + ] + } + """ } sealed trait FunctionSource diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala new file mode 100644 index 0000000000..9c363271ef --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala @@ -0,0 +1,72 @@ +package sttp.tapir.serverless.aws.terraform + +import sttp.tapir.internal._ +import sttp.tapir.{Endpoint, EndpointInput, _} + +import java.util.UUID + +private[terraform] object EndpointsToTerraformConfig { + type IdEndpointInput = (String, EndpointInput.Basic[_]) + + private val headerPrefix = "method.request.header." + private val queryPrefix = "method.request.querystring." + private val pathPrefix = "method.request.path." + + def apply(eps: List[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): AwsTerraformApiGateway = { + val epsBasicInputs: Seq[(Endpoint[_, _, _, _], Vector[IdEndpointInput])] = eps.map { ep => + ep -> ep.input.asVectorOfBasicInputs().map(input => UUID.randomUUID().toString -> input) + } + + val resourceTree = ApiResourceTree(epsBasicInputs.map { case (_, bi) => bi }) + + val methods: Seq[AwsTerraformApiGatewayMethod] = epsBasicInputs.map { case (endpoint, bi) => + val pathComponents: Seq[(PathComponent, String)] = bi + .foldLeft((Seq.empty[(PathComponent, String)], 0)) { case ((acc, c), input) => + input match { + case (id, fp @ EndpointInput.FixedPath(p, _, _)) => (acc :+ PathComponent(id, Left(fp)) -> p, c) + case (id, pc @ EndpointInput.PathCapture(name, _, _)) => + (acc :+ PathComponent(id, Right(pc)) -> name.getOrElse(s"param$c"), if (name.isEmpty) c + 1 else c) + case _ => (acc, c) + } + } + ._1 + + val path = pathComponents + .map { + case (PathComponent(_, Left(_)), p) => p + case (PathComponent(_, Right(_)), p) => s"{$p}" + } + .mkString("/") + + val method = endpoint.httpMethod + + val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map { case (_, name) => name } + val name = (method.map(_.method.toLowerCase).getOrElse("any").capitalize +: nameComponents.map(_.toLowerCase.capitalize)).mkString + + val requestParameters: Seq[(String, Boolean)] = bi.collect { + case (_, EndpointIO.Header(name, codec, _)) => s"$headerPrefix$name" -> !codec.schema.isOptional + case (_, EndpointIO.FixedHeader(header, codec, _)) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional + case (_, EndpointInput.Query(name, codec, _)) => s"$queryPrefix$name" -> !codec.schema.isOptional + } ++ pathComponents.collect { case (_ @PathComponent(_, Right(pc)), _) => + s"$pathPrefix${pc.name.getOrElse("param")}" -> !pc.codec.schema.isOptional + } + + AwsTerraformApiGatewayMethod( + name, + path, + method.map(_.method).getOrElse("ANY"), + pathComponents.map { case (pc, _) => pc }, + requestParameters + ) + } + + AwsTerraformApiGateway(resourceTree, methods) + } +} + +case class PathComponent(id: String, component: Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]]) { + def name: String = component match { + case Left(fp) => fp.s + case Right(pc) => pc.name.getOrElse("param") + } +} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala deleted file mode 100644 index 553de2dab0..0000000000 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformTemplate.scala +++ /dev/null @@ -1,14 +0,0 @@ -package sttp.tapir.serverless.aws.terraform - -import io.circe.Json -import sttp.tapir.Endpoint - -private[terraform] object EndpointsToTerraformTemplate { - def apply(es: List[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): Unit = { - // first create lambda.tf - - - - // then api_gateway.tf - } -} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala index 777bc0077c..95f7ed022c 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala @@ -2,6 +2,7 @@ package sttp.tapir.serverless.aws.terraform import io.circe.Printer import io.circe.syntax._ +import sttp.tapir._ import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ import java.nio.charset.StandardCharsets.UTF_8 @@ -9,15 +10,19 @@ import java.nio.file.{Files, Paths} object TerraformExample extends App { - val methods = List.empty[AwsTerraformApiGatewayMethod] + val eps = List( + endpoint.in("hello" / path[String]("name")).in(header[String]("MyHeader")) + ) implicit val options: AwsTerraformOptions = AwsTerraformOptions( awsRegion = "eu-central-1", - lambdaFunctionName = "Tapir", + functionName = "Tapir", apiGatewayName = "TapirApiGateway", - lambdaSource = S3Source("terraform-example-kubinio", "v1.0.0/example.zip", "nodejs10.x", "main.handler") + functionSource = S3Source("terraform-example-kubinio", "v1.0.0/example.zip", "nodejs10.x", "main.handler") ) + val methods: AwsTerraformApiGateway = EndpointsToTerraformConfig(eps) + val lambdaJson = Printer.spaces2.print(options.asJson) val apiGatewayJson = Printer.spaces2.print(methods.asJson) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala new file mode 100644 index 0000000000..dbc201c8a3 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala @@ -0,0 +1,162 @@ +package sttp.tapir.serverless.aws.terraform + +import io.circe.Json + +import scala.concurrent.duration.FiniteDuration + +sealed trait TerraformResource { + def json(): Json + protected def terraformResource[R](`type`: String, name: String, encoded: Json): Json = + Json.fromFields(Seq(`type` -> Json.fromFields(Seq(name -> encoded)))) +} + +case class AwsLambdaFunction(name: String, timeout: FiniteDuration, memorySize: Int, source: FunctionSource) extends TerraformResource { + override def json(): Json = { + val functionSource: Seq[(String, Json)] = source match { + case s3: S3Source => + Seq( + "s3_bucket" -> Json.fromString(s3.bucket), + "s3_key" -> Json.fromString(s3.key), + "runtime" -> Json.fromString(s3.runtime), + "handler" -> Json.fromString(s3.handler) + ) + case image: ImageSource => Seq("image_uri" -> Json.fromString(image.imageUri)) + case code: CodeSource => + Seq( + "filename" -> Json.fromString(code.fileName), + "runtime" -> Json.fromString(code.runtime), + "handler" -> Json.fromString(code.handler) + ) + } + + val lambdaFunction = Json.fromFields( + Seq( + "function_name" -> Json.fromString(name), + "role" -> Json.fromString("${aws_iam_role.lambda_exec.arn}"), + "timeout" -> Json.fromLong(timeout.toSeconds), + "memory_size" -> Json.fromInt(memorySize) + ) ++ functionSource + ) + + terraformResource("aws_lambda_function", "lambda", lambdaFunction) + } +} + +case class AwsIamRole(assumeRolePolicy: String) extends TerraformResource { + override def json(): Json = terraformResource( + "aws_iam_role", + "lambda_exec", + Json.fromFields( + Seq( + "name" -> Json.fromString("lambda_exec_role"), + "assume_role_policy" -> Json.fromString(assumeRolePolicy) + ) + ) + ) +} + +case class AwsLambdaPermission(apiGatewayName: String) extends TerraformResource { + override def json(): Json = + terraformResource( + "aws_lambda_permission", + "api_gateway_permission", + Json.fromFields( + Seq( + "statement_id" -> Json.fromString("AllowAPIGatewayInvoke"), + "action" -> Json.fromString("lambda:InvokeFunction"), + "function_name" -> Json.fromString(s"$${aws_lambda_function.lambda.function_name}"), + "principal" -> Json.fromString("apigateway.amazonaws.com"), + "source_arn" -> Json.fromString(s"$${aws_api_gateway_rest_api.$apiGatewayName.execution_arn}/*/*") + ) + ) + ) +} + +case class AwsApiGatewayRestApi(name: String, description: String) extends TerraformResource { + override def json(): Json = { + terraformResource( + "aws_api_gateway_rest_api", + s"$name", + Json.fromFields( + Seq( + "name" -> Json.fromString(name), + "description" -> Json.fromString(description) + ) + ) + ) + } +} + +case class AwsApiGatewayResource(name: String, restApiId: String, parentId: String, pathPart: String) extends TerraformResource { + override def json(): Json = + terraformResource( + "aws_api_gateway_resource", + name, + Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "parent_id" -> Json.fromString(s"$${aws_api_gateway_resource.$parentId.id}"), + "path_part" -> Json.fromString(pathPart) + ) + ) + ) +} + +case class AwsApiGatewayMethod( + name: String, + restApiId: String, + resourceId: String, + httpMethod: String, + requestParameters: Seq[(String, Boolean)] +) extends TerraformResource { + override def json(): Json = terraformResource( + "aws_api_gateway_method", + name, + Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "resource_id" -> Json.fromString(s"$${aws_api_gateway_resource.$resourceId.id}"), + "http_method" -> Json.fromString(httpMethod), + "authorization" -> Json.fromString("NONE") + ) ++ (if (requestParameters.nonEmpty) + Seq("request_parameters" -> Json.fromFields(requestParameters.map { case (name, required) => + name -> Json.fromBoolean(required) + })) + else Seq.empty) + ) + ) +} + +case class AwsApiGatewayIntegration(name: String, restApiId: String) extends TerraformResource { + override def json(): Json = terraformResource( + "aws_api_gateway_integration", + name, + Json.fromFields( + Seq( + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "resource_id" -> Json.fromString(s"$${aws_api_gateway_method.$name.resource_id}"), + "http_method" -> Json.fromString(s"$${aws_api_gateway_method.$name.http_method}"), + "integration_http_method" -> Json.fromString("POST"), + "type" -> Json.fromString("AWS_PROXY"), + "uri" -> Json.fromString(s"$${aws_lambda_function.lambda.invoke_arn}") + ) + ) + ) +} + +case class AwsApiGatewayDeployment(name: String, restApiId: String, integrations: Seq[AwsApiGatewayIntegration]) extends TerraformResource { + override def json(): Json = + terraformResource( + "aws_api_gateway_deployment", + name, + Json.fromFields( + Seq( + "depends_on" -> Json.fromValues( + integrations.map { i => Json.fromString(s"aws_api_gateway_integration.${i.name}") } + ), + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "stage_name" -> Json.fromString("test") + ) + ) + ) +} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala deleted file mode 100644 index b557016b2d..0000000000 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala +++ /dev/null @@ -1,3 +0,0 @@ -package sttp.tapir.serverless.aws.terraform - -case class AwsTerraformApiGatewayMethod(httpMethod: String, requestParameters: Map[String, Boolean]) From cb805efc122361c0601b9308248972b3aba8422a Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 25 May 2021 11:02:54 +0200 Subject: [PATCH 23/35] differentiation of endpoints with same path by method --- .../aws/examples/TerraformConfigExample.scala | 5 - .../aws/terraform/ApiResourceTree.scala | 77 ++++-- .../AwsTerraformApiGatewayMethod.scala | 10 - .../aws/terraform/AwsTerraformEncoders.scala | 103 +++----- .../aws/terraform/AwsTerraformOptions.scala | 1 + .../EndpointsToTerraformConfig.scala | 35 +-- .../aws/terraform/TerraformExample.scala | 7 +- .../aws/terraform/TerraformResource.scala | 38 +-- .../serverless/aws/terraform/model.scala | 20 ++ .../test/resources/endpoint_with_params.json | 130 ++++++++++ .../resources/endpoints_common_paths.json | 229 ++++++++++++++++++ .../src/test/resources/simple_endpoint.json | 115 +++++++++ .../terraform/AwsTerraformEncodersTest.scala | 11 - .../VerifyTerraformTemplateTest.scala | 66 +++++ 14 files changed, 701 insertions(+), 146 deletions(-) delete mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala create mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala create mode 100644 serverless/aws/terraform/src/test/resources/endpoint_with_params.json create mode 100644 serverless/aws/terraform/src/test/resources/endpoints_common_paths.json create mode 100644 serverless/aws/terraform/src/test/resources/simple_endpoint.json delete mode 100644 serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala create mode 100644 serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala index 0a2177d87f..79c9c0b8f8 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala @@ -26,14 +26,9 @@ object TerraformConfigExample extends App { ) ) - /** lambda config is created using `AwsTerraformOptions` */ - val lambdaConfig: String = Printer.spaces2.print(terraformOptions.asJson) - - /** api gateway config is created using `Endpoint`s */ val apiGateway: AwsTerraformApiGateway = AwsTerraformInterpreter.toTerraformConfig(helloEndpoint) val apiGatewayConfig = Printer.spaces2.print(apiGateway.asJson) - Files.write(Paths.get("serverless/aws/terraform/example/lambda.tf.json"), lambdaConfig.getBytes(UTF_8)) Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.tf.json"), apiGatewayConfig.getBytes(UTF_8)) } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala index 3c0a8ae6f0..36d7cbede0 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala @@ -1,45 +1,80 @@ package sttp.tapir.serverless.aws.terraform -import sttp.tapir.serverless.aws.terraform.EndpointsToTerraformConfig.IdEndpointInput +import sttp.model.Method +import sttp.tapir.serverless.aws.terraform.EndpointsToTerraformConfig.IdMethodEndpointInput import sttp.tapir.{Codec, EndpointIO, EndpointInput} -/** Creates the tree representation of routes for given endpoints represented as basic inputs */ +import java.util.UUID + private[terraform] object ApiResourceTree { - val RootPath = "/" val RootPathComponent: PathComponent = - PathComponent("root", Left(EndpointInput.FixedPath(RootPath, Codec.idPlain(), EndpointIO.Info.empty))) + PathComponent("root", Method("ANY"), Left(EndpointInput.FixedPath("/", Codec.idPlain(), EndpointIO.Info.empty))) private val pathMatches: (PathComponent, PathComponent) => Boolean = (a, b) => { (a.component, b.component) match { - case (Left(fp1), Left(fp2)) => fp1.s == fp2.s - case (Right(pc1), Right(pc2)) => pc1.name == pc2.name + case (Left(fp1), Left(fp2)) => fp1.s == fp2.s && a.method == b.method + case (Right(pc1), Right(pc2)) => pc1.name == pc2.name && a.method == b.method case _ => false } } - def apply(basicInputs: Seq[Vector[IdEndpointInput]]): ResourceTree = { + def apply(basicInputs: Seq[Vector[IdMethodEndpointInput]]): ResourceTree = { val endpointPathComponents: Seq[Seq[PathComponent]] = basicInputs.map(_.collect { - case (id, fp @ EndpointInput.FixedPath(_, _, _)) => PathComponent(id, Left(fp)) - case (id, pc @ EndpointInput.PathCapture(_, _, _)) => PathComponent(id, Right(pc)) + case (id, m, fp @ EndpointInput.FixedPath(_, _, _)) => PathComponent(id, m, Left(fp)) + case (id, m, pc @ EndpointInput.PathCapture(_, _, _)) => PathComponent(id, m, Right(pc)) }) - def getChildren(level: Int, path: PathComponent): ResourceTree = { - val children: Seq[PathComponent] = endpointPathComponents - .filter(pcs => pcs.lift(level).isDefined && pathMatches(pcs(level - 1), path)) - .map(_(level)) - .groupBy(_.name) - .flatMap { case (_, pcs) => pcs.headOption } - .toSeq + def collectChildren(level: Int, path: PathComponent): ResourceTree = { + val children: Seq[PathComponent] = distinctBy(_.name) { + endpointPathComponents + .filter(pcs => pcs.lift(level).isDefined && pathMatches(pcs(level - 1), path)) + .map(_(level)) + } if (children.isEmpty) ResourceTree(path, Seq.empty) - else ResourceTree(path, children.map(getChildren(level + 1, _))) + else ResourceTree(path, children.map(collectChildren(level + 1, _))) } - val paths0: Seq[PathComponent] = - endpointPathComponents.flatMap(_.headOption).groupBy(_.name).flatMap { case (_, pcs) => pcs.headOption }.toSeq + val paths0: Seq[PathComponent] = distinctBy(pc => (pc.method, pc.name))(endpointPathComponents.flatMap(_.headOption)) - ResourceTree(RootPathComponent, paths0.map(getChildren(1, _))) + ResourceTree(RootPathComponent, paths0.map(collectChildren(1, _))) } + + private def distinctBy[D](value: PathComponent => D)(pcs: Seq[PathComponent]): Seq[PathComponent] = + pcs.groupBy(value).flatMap { case (_, pcs) => pcs.headOption }.toSeq } -case class ResourceTree(pathComponent: PathComponent, children: Seq[ResourceTree]) +case class ResourceTree(path: PathComponent, children: Seq[ResourceTree]) + +object Dupa extends App { + def show(r: ResourceTree): Unit = { + def showResource(nest: Int = 1, r: ResourceTree): Unit = { + if (r.children.nonEmpty) { + r.children.foreach { c => + println(" " * nest + c.path.name + " " + c.path.method.method) + showResource(nest + 1, c) + } + } + } + println(r.path.name + " " + r.path.method.method) + showResource(1, r) + } + + import sttp.tapir._ + import sttp.tapir.internal._ + + val eps = List( + endpoint.get.in("accounts" / path[String]("id")), + endpoint.post.in("accounts"), + endpoint.get.in("accounts" / path[String]("id") / "transactions"), + endpoint.post.in("accounts" / path[String]("id") / "transactions") + ) + + val epsBasicInputs: Seq[(Endpoint[_, _, _, _], Vector[IdMethodEndpointInput])] = eps.map { ep => + ep -> ep.input.asVectorOfBasicInputs().map(input => (UUID.randomUUID().toString, ep.httpMethod.getOrElse(Method("ANY")), input)) + } + + val tree = ApiResourceTree(epsBasicInputs.map(_._2)) + + show(tree) +} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala deleted file mode 100644 index a8eea05ec9..0000000000 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformApiGatewayMethod.scala +++ /dev/null @@ -1,10 +0,0 @@ -package sttp.tapir.serverless.aws.terraform - -case class AwsTerraformApiGateway(resourceTree: ResourceTree, methods: Seq[AwsTerraformApiGatewayMethod]) -case class AwsTerraformApiGatewayMethod( - name: String, - path: String, - httpMethod: String, - pathComponents: Seq[PathComponent], - requestParameters: Seq[(String, Boolean)] -) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala index b8c5f910df..c44b4f4e25 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala @@ -1,93 +1,68 @@ package sttp.tapir.serverless.aws.terraform import io.circe.{Encoder, Json} -import sttp.tapir.serverless.aws.terraform.ApiResourceTree.RootPath +import sttp.tapir.serverless.aws.terraform.TerraformResource.TapirApiGateway object AwsTerraformEncoders { - implicit def encoderAwsTerraformOptions(implicit options: AwsTerraformOptions): Encoder[AwsTerraformOptions] = { - - val provider = Json.fromFields(Seq("aws" -> Json.fromValues(Seq(Json.fromFields(Seq("region" -> Json.fromString(options.awsRegion))))))) - val terraform = Json.fromFields( - Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) - ) - - val lambdaFunction = AwsLambdaFunction(options.functionName, options.timeout, options.memorySize, options.functionSource) - val iAmRole = AwsIamRole(options.assumeRolePolicy.noSpaces) - val permission = AwsLambdaPermission(options.apiGatewayName) - - _ => - Json.fromFields( - Seq( - "terraform" -> terraform, - "provider" -> provider, - "resource" -> Json.fromValues( - Seq( - lambdaFunction.json(), - iAmRole.json(), - permission.json() - ) - ) - ) - ) - } - implicit def encoderAwsTerraformApiGateway(implicit options: AwsTerraformOptions): Encoder[AwsTerraformApiGateway] = gateway => { - def toTerraformResources(parent: String, resource: ResourceTree): Seq[TerraformResource] = { - val name = resource.pathComponent.name - val resourceName = if (parent == RootPath) name else s"$parent-$name" - val apiGatewayResource = AwsApiGatewayResource( - resourceName, - options.apiGatewayName, - if (parent == RootPath) options.apiGatewayName else parent, - resource.pathComponent.component.map(_ => s"{$name}").getOrElse(name) - ) + val provider = + Json.fromFields(Seq("aws" -> Json.fromValues(Seq(Json.fromFields(Seq("region" -> Json.fromString(options.awsRegion))))))) + val terraform = Json.fromFields( + Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) + ) - if (resource.children.isEmpty) { - // reached the leaf - last path component of single endpoint - // integration resource has to be linked to last path component resource - val methodIntegrationResources = - gateway.methods - .find(_.pathComponents.lastOption.exists(_.id == resource.pathComponent.id)) - .map { method => - Seq( - AwsApiGatewayMethod(method.name, options.apiGatewayName, resourceName, method.httpMethod, method.requestParameters), - AwsApiGatewayIntegration(method.name, options.apiGatewayName) - ) - } - .getOrElse(Seq.empty) + def toTerraformResources(parent: String, tree: ResourceTree, acc: Map[String, AwsApiGatewayResource]): Seq[TerraformResource] = { + println(acc.size) - Seq(apiGatewayResource) ++ methodIntegrationResources + val name = tree.path.name + val resourceName = if (parent == TapirApiGateway) name else s"$parent-$name" + val apiGatewayResource = + AwsApiGatewayResource(resourceName, parentId = parent, tree.path.component.map(_ => s"{$name}").getOrElse(name)) - } else Seq(apiGatewayResource) ++ resource.children.flatMap(child => toTerraformResources(parent = resourceName, child)) - } + val methodResources = + gateway.methods + .find(_.paths.lastOption.exists(_.id == tree.path.id)) + .map { m => + val resourceId = acc.getOrElse(m.paths.last.id, apiGatewayResource).name + Seq( + AwsApiGatewayMethod(m.name, resourceId, m.httpMethod.method, m.requestParameters), + AwsApiGatewayIntegration(m.name) + ) + } + .getOrElse(Seq.empty) - val pathComponentResources: Seq[TerraformResource] = - gateway.resourceTree.children.flatMap(child => toTerraformResources(RootPath, child)) + if (tree.children.isEmpty) + methodResources ++ (apiGatewayResource +: acc.values.groupBy(_.pathPart).flatMap(_._2.headOption).toSeq) + else + methodResources ++ tree.children.flatMap(child => toTerraformResources(parent = resourceName, child, acc + (tree.path.id -> apiGatewayResource))) + } - val apiGatewayDeployment = AwsApiGatewayDeployment( - options.apiGatewayName, - options.apiGatewayName, - pathComponentResources.collect { case i @ AwsApiGatewayIntegration(_, _) => i } - ) + val endpointResources: Seq[TerraformResource] = + gateway.resourceTree.children.flatMap(child => toTerraformResources(parent = TapirApiGateway, child, Map.empty)) val output = Json.fromFields( Seq( "base_url" -> Json.fromFields( - Seq("value" -> Json.fromString(s"$${aws_api_gateway_deployment.${options.apiGatewayName}.invoke_url}")) + Seq("value" -> Json.fromString(s"$${aws_api_gateway_deployment.$TapirApiGateway.invoke_url}")) ) ) ) Json.fromFields( Seq( + "terraform" -> terraform, + "provider" -> provider, "resource" -> Json.fromValues( Seq( - AwsApiGatewayRestApi(options.apiGatewayName, "description").json(), - apiGatewayDeployment.json() - ) ++ pathComponentResources.map(_.json()) + AwsLambdaFunction(options.functionName, options.timeout, options.memorySize, options.functionSource).json(), + AwsIamRole(options.assumeRolePolicy.noSpaces).json(), + AwsLambdaPermission.json(), + AwsApiGatewayRestApi(options.apiGatewayName, options.apiGatewayDescription).json(), + AwsApiGatewayDeployment(endpointResources.collect { case i @ AwsApiGatewayIntegration(_) => i.name }).json() + ) ++ endpointResources.map(_.json()) ), "output" -> output ) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala index 701e412e94..0b2a4192df 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala @@ -10,6 +10,7 @@ case class AwsTerraformOptions( awsRegion: String, functionName: String, apiGatewayName: String, + apiGatewayDescription: String = "Serverless Application", assumeRolePolicy: Json = lambdaDefaultAssumeRolePolicy, functionSource: FunctionSource, timeout: FiniteDuration = 10.seconds, diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala index 9c363271ef..d476258601 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala @@ -1,31 +1,34 @@ package sttp.tapir.serverless.aws.terraform +import sttp.model.Method import sttp.tapir.internal._ import sttp.tapir.{Endpoint, EndpointInput, _} import java.util.UUID private[terraform] object EndpointsToTerraformConfig { - type IdEndpointInput = (String, EndpointInput.Basic[_]) + type IdMethodEndpointInput = (String, Method, EndpointInput.Basic[_]) private val headerPrefix = "method.request.header." private val queryPrefix = "method.request.querystring." private val pathPrefix = "method.request.path." def apply(eps: List[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): AwsTerraformApiGateway = { - val epsBasicInputs: Seq[(Endpoint[_, _, _, _], Vector[IdEndpointInput])] = eps.map { ep => - ep -> ep.input.asVectorOfBasicInputs().map(input => UUID.randomUUID().toString -> input) + val epsBasicInputs: Seq[(Endpoint[_, _, _, _], Vector[IdMethodEndpointInput])] = eps.map { ep => + ep -> ep.input.asVectorOfBasicInputs().map(input => (UUID.randomUUID().toString, ep.httpMethod.getOrElse(Method("ANY")), input)) } val resourceTree = ApiResourceTree(epsBasicInputs.map { case (_, bi) => bi }) val methods: Seq[AwsTerraformApiGatewayMethod] = epsBasicInputs.map { case (endpoint, bi) => + val method = endpoint.httpMethod.getOrElse(Method("ANY")) + val pathComponents: Seq[(PathComponent, String)] = bi .foldLeft((Seq.empty[(PathComponent, String)], 0)) { case ((acc, c), input) => input match { - case (id, fp @ EndpointInput.FixedPath(p, _, _)) => (acc :+ PathComponent(id, Left(fp)) -> p, c) - case (id, pc @ EndpointInput.PathCapture(name, _, _)) => - (acc :+ PathComponent(id, Right(pc)) -> name.getOrElse(s"param$c"), if (name.isEmpty) c + 1 else c) + case (id, m, fp @ EndpointInput.FixedPath(p, _, _)) => (acc :+ PathComponent(id, m, Left(fp)) -> p, c) + case (id, m, pc @ EndpointInput.PathCapture(name, _, _)) => + (acc :+ PathComponent(id, m, Right(pc)) -> name.getOrElse(s"param$c"), if (name.isEmpty) c + 1 else c) case _ => (acc, c) } } @@ -33,28 +36,26 @@ private[terraform] object EndpointsToTerraformConfig { val path = pathComponents .map { - case (PathComponent(_, Left(_)), p) => p - case (PathComponent(_, Right(_)), p) => s"{$p}" + case (PathComponent(_, _, Left(_)), p) => p + case (PathComponent(_, _, Right(_)), p) => s"{$p}" } .mkString("/") - val method = endpoint.httpMethod - val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map { case (_, name) => name } - val name = (method.map(_.method.toLowerCase).getOrElse("any").capitalize +: nameComponents.map(_.toLowerCase.capitalize)).mkString + val name = s"${method.method.toLowerCase.capitalize}${nameComponents.map(_.toLowerCase.capitalize).mkString}" val requestParameters: Seq[(String, Boolean)] = bi.collect { - case (_, EndpointIO.Header(name, codec, _)) => s"$headerPrefix$name" -> !codec.schema.isOptional - case (_, EndpointIO.FixedHeader(header, codec, _)) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional - case (_, EndpointInput.Query(name, codec, _)) => s"$queryPrefix$name" -> !codec.schema.isOptional - } ++ pathComponents.collect { case (_ @PathComponent(_, Right(pc)), _) => + case (_, _, EndpointIO.Header(name, codec, _)) => s"$headerPrefix$name" -> !codec.schema.isOptional + case (_, _, EndpointIO.FixedHeader(header, codec, _)) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional + case (_, _, EndpointInput.Query(name, codec, _)) => s"$queryPrefix$name" -> !codec.schema.isOptional + } ++ pathComponents.collect { case (_ @PathComponent(_, _, Right(pc)), _) => s"$pathPrefix${pc.name.getOrElse("param")}" -> !pc.codec.schema.isOptional } AwsTerraformApiGatewayMethod( name, path, - method.map(_.method).getOrElse("ANY"), + method, pathComponents.map { case (pc, _) => pc }, requestParameters ) @@ -64,7 +65,7 @@ private[terraform] object EndpointsToTerraformConfig { } } -case class PathComponent(id: String, component: Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]]) { +case class PathComponent(id: String, method: Method, component: Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]]) { def name: String = component match { case Left(fp) => fp.s case Right(pc) => pc.name.getOrElse("param") diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala index 95f7ed022c..1292af3912 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala @@ -11,7 +11,10 @@ import java.nio.file.{Files, Paths} object TerraformExample extends App { val eps = List( - endpoint.in("hello" / path[String]("name")).in(header[String]("MyHeader")) + endpoint.in("accounts" / path[String]("id") / "transactions"), + endpoint.in("accounts" / path[String]("id") / "history"), + endpoint.in("accounts" / path[String]("id") / "credit"), + endpoint.in("accounts" / path[String]("id") / "info") ) implicit val options: AwsTerraformOptions = AwsTerraformOptions( @@ -23,9 +26,7 @@ object TerraformExample extends App { val methods: AwsTerraformApiGateway = EndpointsToTerraformConfig(eps) - val lambdaJson = Printer.spaces2.print(options.asJson) val apiGatewayJson = Printer.spaces2.print(methods.asJson) - Files.write(Paths.get("serverless/aws/terraform/example/lambda.tf.json"), lambdaJson.getBytes(UTF_8)) Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.tf.json"), apiGatewayJson.getBytes(UTF_8)) } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala index dbc201c8a3..36159af9a7 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala @@ -1,12 +1,18 @@ package sttp.tapir.serverless.aws.terraform import io.circe.Json +import sttp.tapir.serverless.aws.terraform.TerraformResource.{TapirApiGateway, terraformResource} import scala.concurrent.duration.FiniteDuration sealed trait TerraformResource { def json(): Json - protected def terraformResource[R](`type`: String, name: String, encoded: Json): Json = +} + +private[terraform] object TerraformResource { + val TapirApiGateway = "TapirApiGateway" + + def terraformResource[R](`type`: String, name: String, encoded: Json): Json = Json.fromFields(Seq(`type` -> Json.fromFields(Seq(name -> encoded)))) } @@ -55,7 +61,7 @@ case class AwsIamRole(assumeRolePolicy: String) extends TerraformResource { ) } -case class AwsLambdaPermission(apiGatewayName: String) extends TerraformResource { +case object AwsLambdaPermission extends TerraformResource { override def json(): Json = terraformResource( "aws_lambda_permission", @@ -66,7 +72,7 @@ case class AwsLambdaPermission(apiGatewayName: String) extends TerraformResource "action" -> Json.fromString("lambda:InvokeFunction"), "function_name" -> Json.fromString(s"$${aws_lambda_function.lambda.function_name}"), "principal" -> Json.fromString("apigateway.amazonaws.com"), - "source_arn" -> Json.fromString(s"$${aws_api_gateway_rest_api.$apiGatewayName.execution_arn}/*/*") + "source_arn" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.execution_arn}/*/*") ) ) ) @@ -76,7 +82,7 @@ case class AwsApiGatewayRestApi(name: String, description: String) extends Terra override def json(): Json = { terraformResource( "aws_api_gateway_rest_api", - s"$name", + TapirApiGateway, Json.fromFields( Seq( "name" -> Json.fromString(name), @@ -87,15 +93,18 @@ case class AwsApiGatewayRestApi(name: String, description: String) extends Terra } } -case class AwsApiGatewayResource(name: String, restApiId: String, parentId: String, pathPart: String) extends TerraformResource { +case class AwsApiGatewayResource(name: String, parentId: String, pathPart: String) extends TerraformResource { override def json(): Json = terraformResource( "aws_api_gateway_resource", name, Json.fromFields( Seq( - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), - "parent_id" -> Json.fromString(s"$${aws_api_gateway_resource.$parentId.id}"), + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), + "parent_id" -> Json.fromString( + if (parentId == TapirApiGateway) s"$${aws_api_gateway_rest_api.$TapirApiGateway.root_resource_id}" + else s"$${aws_api_gateway_resource.$parentId.id}" + ), "path_part" -> Json.fromString(pathPart) ) ) @@ -104,7 +113,6 @@ case class AwsApiGatewayResource(name: String, restApiId: String, parentId: Stri case class AwsApiGatewayMethod( name: String, - restApiId: String, resourceId: String, httpMethod: String, requestParameters: Seq[(String, Boolean)] @@ -114,7 +122,7 @@ case class AwsApiGatewayMethod( name, Json.fromFields( Seq( - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), "resource_id" -> Json.fromString(s"$${aws_api_gateway_resource.$resourceId.id}"), "http_method" -> Json.fromString(httpMethod), "authorization" -> Json.fromString("NONE") @@ -127,13 +135,13 @@ case class AwsApiGatewayMethod( ) } -case class AwsApiGatewayIntegration(name: String, restApiId: String) extends TerraformResource { +case class AwsApiGatewayIntegration(name: String) extends TerraformResource { override def json(): Json = terraformResource( "aws_api_gateway_integration", name, Json.fromFields( Seq( - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), "resource_id" -> Json.fromString(s"$${aws_api_gateway_method.$name.resource_id}"), "http_method" -> Json.fromString(s"$${aws_api_gateway_method.$name.http_method}"), "integration_http_method" -> Json.fromString("POST"), @@ -144,17 +152,17 @@ case class AwsApiGatewayIntegration(name: String, restApiId: String) extends Ter ) } -case class AwsApiGatewayDeployment(name: String, restApiId: String, integrations: Seq[AwsApiGatewayIntegration]) extends TerraformResource { +case class AwsApiGatewayDeployment(dependsOn: Seq[String]) extends TerraformResource { override def json(): Json = terraformResource( "aws_api_gateway_deployment", - name, + TapirApiGateway, Json.fromFields( Seq( "depends_on" -> Json.fromValues( - integrations.map { i => Json.fromString(s"aws_api_gateway_integration.${i.name}") } + dependsOn.map { d => Json.fromString(s"aws_api_gateway_integration.$d") } ), - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$restApiId.id}"), + "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), "stage_name" -> Json.fromString("test") ) ) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala new file mode 100644 index 0000000000..ab13cc06b2 --- /dev/null +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -0,0 +1,20 @@ +package sttp.tapir.serverless.aws.terraform +import io.circe.Printer +import io.circe.syntax._ +import sttp.model.Method +import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ + +case class AwsTerraformApiGateway(resourceTree: ResourceTree, methods: Seq[AwsTerraformApiGatewayMethod]) { + def toJson()(implicit options: AwsTerraformOptions): String = { + val gateway = this + Printer.spaces2.print(gateway.asJson(AwsTerraformEncoders.encoderAwsTerraformApiGateway)) + } +} + +case class AwsTerraformApiGatewayMethod( + name: String, + path: String, + httpMethod: Method, + paths: Seq[PathComponent], + requestParameters: Seq[(String, Boolean)] +) diff --git a/serverless/aws/terraform/src/test/resources/endpoint_with_params.json b/serverless/aws/terraform/src/test/resources/endpoint_with_params.json new file mode 100644 index 0000000000..29826607de --- /dev/null +++ b/serverless/aws/terraform/src/test/resources/endpoint_with_params.json @@ -0,0 +1,130 @@ +{ + "terraform" : { + "required_providers" : { + "aws" : { + "source" : "hashicorp/aws" + } + } + }, + "provider" : { + "aws" : [ + { + "region" : "eu-central-1" + } + ] + }, + "resource" : [ + { + "aws_lambda_function" : { + "lambda" : { + "function_name" : "Tapir", + "role" : "${aws_iam_role.lambda_exec.arn}", + "timeout" : 10, + "memory_size" : 256, + "s3_bucket" : "bucket", + "s3_key" : "key", + "runtime" : "java11", + "handler" : "Handler::handleRequest" + } + } + }, + { + "aws_iam_role" : { + "lambda_exec" : { + "name" : "lambda_exec_role", + "assume_role_policy" : "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Effect\":\"Allow\",\"Sid\":\"\"}]}" + } + } + }, + { + "aws_lambda_permission" : { + "api_gateway_permission" : { + "statement_id" : "AllowAPIGatewayInvoke", + "action" : "lambda:InvokeFunction", + "function_name" : "${aws_lambda_function.lambda.function_name}", + "principal" : "apigateway.amazonaws.com", + "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + } + } + }, + { + "aws_api_gateway_rest_api" : { + "TapirApiGateway" : { + "name" : "TapirApiGateway", + "description" : "Serverless Application" + } + } + }, + { + "aws_api_gateway_deployment" : { + "TapirApiGateway" : { + "depends_on" : [ + "aws_api_gateway_integration.GetAccountsIdHistory" + ], + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "stage_name" : "test" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", + "path_part" : "accounts" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts-id" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.accounts.id}", + "path_part" : "{id}" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts-id-history" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.accounts-id.id}", + "path_part" : "history" + } + } + }, + { + "aws_api_gateway_method" : { + "GetAccountsIdHistory" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_resource.accounts-id-history.id}", + "http_method" : "GET", + "authorization" : "NONE", + "request_parameters" : { + "method.request.querystring.limit" : true, + "method.request.header.X-Account" : true, + "method.request.header.X-Secret" : true, + "method.request.path.id" : true + } + } + } + }, + { + "aws_api_gateway_integration" : { + "GetAccountsIdHistory" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.GetAccountsIdHistory.resource_id}", + "http_method" : "${aws_api_gateway_method.GetAccountsIdHistory.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + } + ], + "output" : { + "base_url" : { + "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + } + } +} \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json b/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json new file mode 100644 index 0000000000..0a7bcc2314 --- /dev/null +++ b/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json @@ -0,0 +1,229 @@ +{ + "terraform" : { + "required_providers" : { + "aws" : { + "source" : "hashicorp/aws" + } + } + }, + "provider" : { + "aws" : [ + { + "region" : "eu-central-1" + } + ] + }, + "resource" : [ + { + "aws_lambda_function" : { + "lambda" : { + "function_name" : "Tapir", + "role" : "${aws_iam_role.lambda_exec.arn}", + "timeout" : 10, + "memory_size" : 256, + "s3_bucket" : "bucket", + "s3_key" : "key", + "runtime" : "java11", + "handler" : "Handler::handleRequest" + } + } + }, + { + "aws_iam_role" : { + "lambda_exec" : { + "name" : "lambda_exec_role", + "assume_role_policy" : "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Effect\":\"Allow\",\"Sid\":\"\"}]}" + } + } + }, + { + "aws_lambda_permission" : { + "api_gateway_permission" : { + "statement_id" : "AllowAPIGatewayInvoke", + "action" : "lambda:InvokeFunction", + "function_name" : "${aws_lambda_function.lambda.function_name}", + "principal" : "apigateway.amazonaws.com", + "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + } + } + }, + { + "aws_api_gateway_rest_api" : { + "TapirApiGateway" : { + "name" : "TapirApiGateway", + "description" : "Serverless Application" + } + } + }, + { + "aws_api_gateway_deployment" : { + "TapirApiGateway" : { + "depends_on" : [ + "aws_api_gateway_integration.PostAccounts", + "aws_api_gateway_integration.PostAccountsIdTransactions", + "aws_api_gateway_integration.GetAccountsId", + "aws_api_gateway_integration.GetAccountsIdTransactions" + ], + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "stage_name" : "test" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", + "path_part" : "accounts" + } + } + }, + { + "aws_api_gateway_method" : { + "PostAccounts" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_resource.accounts.id}", + "http_method" : "POST", + "authorization" : "NONE" + } + } + }, + { + "aws_api_gateway_integration" : { + "PostAccounts" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.PostAccounts.resource_id}", + "http_method" : "${aws_api_gateway_method.PostAccounts.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts-id" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.accounts.id}", + "path_part" : "{id}" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts-id-transactions" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.accounts-id.id}", + "path_part" : "transactions" + } + } + }, + { + "aws_api_gateway_method" : { + "PostAccountsIdTransactions" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", + "http_method" : "POST", + "authorization" : "NONE", + "request_parameters" : { + "method.request.path.id" : true + } + } + } + }, + { + "aws_api_gateway_integration" : { + "PostAccountsIdTransactions" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.PostAccountsIdTransactions.resource_id}", + "http_method" : "${aws_api_gateway_method.PostAccountsIdTransactions.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", + "path_part" : "accounts" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts-id" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.accounts.id}", + "path_part" : "{id}" + } + } + }, + { + "aws_api_gateway_method" : { + "GetAccountsId" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_resource.accounts-id.id}", + "http_method" : "GET", + "authorization" : "NONE", + "request_parameters" : { + "method.request.path.id" : true + } + } + } + }, + { + "aws_api_gateway_integration" : { + "GetAccountsId" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.GetAccountsId.resource_id}", + "http_method" : "${aws_api_gateway_method.GetAccountsId.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + }, + { + "aws_api_gateway_resource" : { + "accounts-id-transactions" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.accounts-id.id}", + "path_part" : "transactions" + } + } + }, + { + "aws_api_gateway_method" : { + "GetAccountsIdTransactions" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", + "http_method" : "GET", + "authorization" : "NONE", + "request_parameters" : { + "method.request.path.id" : true + } + } + } + }, + { + "aws_api_gateway_integration" : { + "GetAccountsIdTransactions" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.GetAccountsIdTransactions.resource_id}", + "http_method" : "${aws_api_gateway_method.GetAccountsIdTransactions.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + } + ], + "output" : { + "base_url" : { + "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + } + } +} \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/resources/simple_endpoint.json b/serverless/aws/terraform/src/test/resources/simple_endpoint.json new file mode 100644 index 0000000000..31043d2380 --- /dev/null +++ b/serverless/aws/terraform/src/test/resources/simple_endpoint.json @@ -0,0 +1,115 @@ +{ + "terraform" : { + "required_providers" : { + "aws" : { + "source" : "hashicorp/aws" + } + } + }, + "provider" : { + "aws" : [ + { + "region" : "eu-central-1" + } + ] + }, + "resource" : [ + { + "aws_lambda_function" : { + "lambda" : { + "function_name" : "Tapir", + "role" : "${aws_iam_role.lambda_exec.arn}", + "timeout" : 10, + "memory_size" : 256, + "s3_bucket" : "bucket", + "s3_key" : "key", + "runtime" : "java11", + "handler" : "Handler::handleRequest" + } + } + }, + { + "aws_iam_role" : { + "lambda_exec" : { + "name" : "lambda_exec_role", + "assume_role_policy" : "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Effect\":\"Allow\",\"Sid\":\"\"}]}" + } + } + }, + { + "aws_lambda_permission" : { + "api_gateway_permission" : { + "statement_id" : "AllowAPIGatewayInvoke", + "action" : "lambda:InvokeFunction", + "function_name" : "${aws_lambda_function.lambda.function_name}", + "principal" : "apigateway.amazonaws.com", + "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + } + } + }, + { + "aws_api_gateway_rest_api" : { + "TapirApiGateway" : { + "name" : "TapirApiGateway", + "description" : "Serverless Application" + } + } + }, + { + "aws_api_gateway_deployment" : { + "TapirApiGateway" : { + "depends_on" : [ + "aws_api_gateway_integration.GetHelloWorld" + ], + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "stage_name" : "test" + } + } + }, + { + "aws_api_gateway_resource" : { + "hello" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", + "path_part" : "hello" + } + } + }, + { + "aws_api_gateway_resource" : { + "hello-world" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "parent_id" : "${aws_api_gateway_resource.hello.id}", + "path_part" : "world" + } + } + }, + { + "aws_api_gateway_method" : { + "GetHelloWorld" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_resource.hello-world.id}", + "http_method" : "GET", + "authorization" : "NONE" + } + } + }, + { + "aws_api_gateway_integration" : { + "GetHelloWorld" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.GetHelloWorld.resource_id}", + "http_method" : "${aws_api_gateway_method.GetHelloWorld.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + } + ], + "output" : { + "base_url" : { + "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + } + } +} \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala deleted file mode 100644 index 7206f86c9d..0000000000 --- a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncodersTest.scala +++ /dev/null @@ -1,11 +0,0 @@ -package sttp.tapir.serverless.aws.terraform - -import io.circe.Printer -import io.circe.syntax._ -import org.scalatest.funsuite.AnyFunSuite -import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ - -class AwsTerraformEncodersTest extends AnyFunSuite { - - -} diff --git a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala new file mode 100644 index 0000000000..b9683cda9e --- /dev/null +++ b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala @@ -0,0 +1,66 @@ +package sttp.tapir.serverless.aws.terraform + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import sttp.tapir._ +import sttp.tapir.serverless.aws.terraform.VerifyTerraformTemplateTest.{load, noIndentation} + +import scala.io.Source + +class VerifyTerraformTemplateTest extends AnyFunSuite with Matchers { + + private implicit val options: AwsTerraformOptions = AwsTerraformOptions( + awsRegion = "eu-central-1", + functionName = "Tapir", + apiGatewayName = "TapirApiGateway", + functionSource = S3Source("bucket", "key", "java11", "Handler::handleRequest") + ) + + test("should handle empty endpoint list") { + AwsTerraformInterpreter.toTerraformConfig(List.empty).toJson() + } + + test("should match expected json simple endpoint") { + val ep = endpoint.get.in("hello" / "world") + + val expectedJson = load("simple_endpoint.json") + val actualJson = AwsTerraformInterpreter.toTerraformConfig(List(ep)).toJson() + + expectedJson shouldBe noIndentation(actualJson) + } + + test("should match expected json endpoint with params") { + val ep = endpoint.get + .in("accounts" / path[String]("id") / "history") + .in(query[Int]("limit")) + .in(header[String]("X-Account")) + .in(header[String]("X-Secret")) + + val expectedJson = load("endpoint_with_params.json") + val actualJson = AwsTerraformInterpreter.toTerraformConfig(List(ep)).toJson() + + expectedJson shouldBe noIndentation(actualJson) + } + + test("should match expected json endpoints with common path") { + val eps = List( + endpoint.get.in("accounts" / path[String]("id")), + endpoint.post.in("accounts"), + endpoint.get.in("accounts" / path[String]("id") / "transactions"), + endpoint.post.in("accounts" / path[String]("id") / "transactions") + ) + + val actualJson = AwsTerraformInterpreter.toTerraformConfig(eps).toJson() + + println(actualJson) + } +} + +object VerifyTerraformTemplateTest { + + def load(fileName: String): String = { + noIndentation(Source.fromInputStream(getClass.getResourceAsStream(s"/$fileName")).getLines().mkString("\n")) + } + + def noIndentation(s: String): String = s.replaceAll("[ \t]", "").trim +} From 485cc45cebf0c66026225144162600cf5dffd0d6 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 25 May 2021 11:42:06 +0200 Subject: [PATCH 24/35] fix duplicated path resources --- .../aws/terraform/AwsTerraformEncoders.scala | 49 ++++++---- .../aws/terraform/TerraformExample.scala | 5 +- .../aws/terraform/TerraformResource.scala | 5 +- .../resources/endpoints_common_paths.json | 89 ++++++----------- .../src/test/resources/root_endpoint.json | 97 +++++++++++++++++++ .../VerifyTerraformTemplateTest.scala | 13 ++- 6 files changed, 173 insertions(+), 85 deletions(-) create mode 100644 serverless/aws/terraform/src/test/resources/root_endpoint.json diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala index c44b4f4e25..58435c7d4b 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala @@ -14,34 +14,34 @@ object AwsTerraformEncoders { Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) ) - def toTerraformResources(parent: String, tree: ResourceTree, acc: Map[String, AwsApiGatewayResource]): Seq[TerraformResource] = { - println(acc.size) - + def toTerraformResources(parent: String, tree: ResourceTree): Seq[(String, AwsApiGatewayResource)] = { val name = tree.path.name val resourceName = if (parent == TapirApiGateway) name else s"$parent-$name" val apiGatewayResource = AwsApiGatewayResource(resourceName, parentId = parent, tree.path.component.map(_ => s"{$name}").getOrElse(name)) - val methodResources = - gateway.methods - .find(_.paths.lastOption.exists(_.id == tree.path.id)) - .map { m => - val resourceId = acc.getOrElse(m.paths.last.id, apiGatewayResource).name - Seq( - AwsApiGatewayMethod(m.name, resourceId, m.httpMethod.method, m.requestParameters), - AwsApiGatewayIntegration(m.name) - ) - } - .getOrElse(Seq.empty) - if (tree.children.isEmpty) - methodResources ++ (apiGatewayResource +: acc.values.groupBy(_.pathPart).flatMap(_._2.headOption).toSeq) + Seq(tree.path.id -> apiGatewayResource) else - methodResources ++ tree.children.flatMap(child => toTerraformResources(parent = resourceName, child, acc + (tree.path.id -> apiGatewayResource))) + (tree.path.id -> apiGatewayResource) +: tree.children.flatMap(child => toTerraformResources(parent = resourceName, child)) } - val endpointResources: Seq[TerraformResource] = - gateway.resourceTree.children.flatMap(child => toTerraformResources(parent = TapirApiGateway, child, Map.empty)) + val pathResources: Seq[(String, AwsApiGatewayResource)] = + gateway.resourceTree.children.flatMap(toTerraformResources(TapirApiGateway, _)) + + val methodResources: Seq[TerraformResource] = + gateway.methods + .flatMap { m => + val resourceId = + pathResources + .find { case (id, _) => m.paths.lastOption.map(_.id).getOrElse(TapirApiGateway) == id } + .map { case (_, res) => res.name } + .getOrElse(TapirApiGateway) + Seq( + AwsApiGatewayMethod(m.name, resourceId, m.httpMethod.method, m.requestParameters), + AwsApiGatewayIntegration(m.name) + ) + } val output = Json.fromFields( Seq( @@ -61,8 +61,15 @@ object AwsTerraformEncoders { AwsIamRole(options.assumeRolePolicy.noSpaces).json(), AwsLambdaPermission.json(), AwsApiGatewayRestApi(options.apiGatewayName, options.apiGatewayDescription).json(), - AwsApiGatewayDeployment(endpointResources.collect { case i @ AwsApiGatewayIntegration(_) => i.name }).json() - ) ++ endpointResources.map(_.json()) + AwsApiGatewayDeployment(methodResources.collect { case i @ AwsApiGatewayIntegration(_) => i.name }).json() + ) + ++ pathResources + .groupBy { case (_, res) => res.name } + .flatMap { case (_, res) => res.headOption } + .toSeq + .sortBy { case (_, res) => res.name.length } + .map { case (_, res) => res.json() } + ++ methodResources.map(_.json()) ), "output" -> output ) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala index 1292af3912..58c159d86a 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala @@ -11,10 +11,7 @@ import java.nio.file.{Files, Paths} object TerraformExample extends App { val eps = List( - endpoint.in("accounts" / path[String]("id") / "transactions"), - endpoint.in("accounts" / path[String]("id") / "history"), - endpoint.in("accounts" / path[String]("id") / "credit"), - endpoint.in("accounts" / path[String]("id") / "info") + endpoint ) implicit val options: AwsTerraformOptions = AwsTerraformOptions( diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala index 36159af9a7..fb84520146 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala @@ -123,7 +123,10 @@ case class AwsApiGatewayMethod( Json.fromFields( Seq( "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), - "resource_id" -> Json.fromString(s"$${aws_api_gateway_resource.$resourceId.id}"), + "resource_id" -> Json.fromString( + if (resourceId == TapirApiGateway) s"$${aws_api_gateway_rest_api.$TapirApiGateway.root_resource_id}" + else s"$${aws_api_gateway_resource.$resourceId.id}" + ), "http_method" -> Json.fromString(httpMethod), "authorization" -> Json.fromString("NONE") ) ++ (if (requestParameters.nonEmpty) diff --git a/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json b/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json index 0a7bcc2314..439b55688e 100644 --- a/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json +++ b/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json @@ -59,10 +59,10 @@ "aws_api_gateway_deployment" : { "TapirApiGateway" : { "depends_on" : [ - "aws_api_gateway_integration.PostAccounts", - "aws_api_gateway_integration.PostAccountsIdTransactions", "aws_api_gateway_integration.GetAccountsId", - "aws_api_gateway_integration.GetAccountsIdTransactions" + "aws_api_gateway_integration.PostAccounts", + "aws_api_gateway_integration.GetAccountsIdTransactions", + "aws_api_gateway_integration.PostAccountsIdTransactions" ], "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", "stage_name" : "test" @@ -78,28 +78,6 @@ } } }, - { - "aws_api_gateway_method" : { - "PostAccounts" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts.id}", - "http_method" : "POST", - "authorization" : "NONE" - } - } - }, - { - "aws_api_gateway_integration" : { - "PostAccounts" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.PostAccounts.resource_id}", - "http_method" : "${aws_api_gateway_method.PostAccounts.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" - } - } - }, { "aws_api_gateway_resource" : { "accounts-id" : { @@ -120,10 +98,10 @@ }, { "aws_api_gateway_method" : { - "PostAccountsIdTransactions" : { + "GetAccountsId" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", - "http_method" : "POST", + "resource_id" : "${aws_api_gateway_resource.accounts-id.id}", + "http_method" : "GET", "authorization" : "NONE", "request_parameters" : { "method.request.path.id" : true @@ -133,10 +111,10 @@ }, { "aws_api_gateway_integration" : { - "PostAccountsIdTransactions" : { + "GetAccountsId" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.PostAccountsIdTransactions.resource_id}", - "http_method" : "${aws_api_gateway_method.PostAccountsIdTransactions.http_method}", + "resource_id" : "${aws_api_gateway_method.GetAccountsId.resource_id}", + "http_method" : "${aws_api_gateway_method.GetAccountsId.http_method}", "integration_http_method" : "POST", "type" : "AWS_PROXY", "uri" : "${aws_lambda_function.lambda.invoke_arn}" @@ -144,28 +122,32 @@ } }, { - "aws_api_gateway_resource" : { - "accounts" : { + "aws_api_gateway_method" : { + "PostAccounts" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", - "path_part" : "accounts" + "resource_id" : "${aws_api_gateway_resource.accounts.id}", + "http_method" : "POST", + "authorization" : "NONE" } } }, { - "aws_api_gateway_resource" : { - "accounts-id" : { + "aws_api_gateway_integration" : { + "PostAccounts" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.accounts.id}", - "path_part" : "{id}" + "resource_id" : "${aws_api_gateway_method.PostAccounts.resource_id}", + "http_method" : "${aws_api_gateway_method.PostAccounts.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" } } }, { "aws_api_gateway_method" : { - "GetAccountsId" : { + "GetAccountsIdTransactions" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts-id.id}", + "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", "http_method" : "GET", "authorization" : "NONE", "request_parameters" : { @@ -176,31 +158,22 @@ }, { "aws_api_gateway_integration" : { - "GetAccountsId" : { + "GetAccountsIdTransactions" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.GetAccountsId.resource_id}", - "http_method" : "${aws_api_gateway_method.GetAccountsId.http_method}", + "resource_id" : "${aws_api_gateway_method.GetAccountsIdTransactions.resource_id}", + "http_method" : "${aws_api_gateway_method.GetAccountsIdTransactions.http_method}", "integration_http_method" : "POST", "type" : "AWS_PROXY", "uri" : "${aws_lambda_function.lambda.invoke_arn}" } } }, - { - "aws_api_gateway_resource" : { - "accounts-id-transactions" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.accounts-id.id}", - "path_part" : "transactions" - } - } - }, { "aws_api_gateway_method" : { - "GetAccountsIdTransactions" : { + "PostAccountsIdTransactions" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", - "http_method" : "GET", + "http_method" : "POST", "authorization" : "NONE", "request_parameters" : { "method.request.path.id" : true @@ -210,10 +183,10 @@ }, { "aws_api_gateway_integration" : { - "GetAccountsIdTransactions" : { + "PostAccountsIdTransactions" : { "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.GetAccountsIdTransactions.resource_id}", - "http_method" : "${aws_api_gateway_method.GetAccountsIdTransactions.http_method}", + "resource_id" : "${aws_api_gateway_method.PostAccountsIdTransactions.resource_id}", + "http_method" : "${aws_api_gateway_method.PostAccountsIdTransactions.http_method}", "integration_http_method" : "POST", "type" : "AWS_PROXY", "uri" : "${aws_lambda_function.lambda.invoke_arn}" diff --git a/serverless/aws/terraform/src/test/resources/root_endpoint.json b/serverless/aws/terraform/src/test/resources/root_endpoint.json new file mode 100644 index 0000000000..0075d3b402 --- /dev/null +++ b/serverless/aws/terraform/src/test/resources/root_endpoint.json @@ -0,0 +1,97 @@ +{ + "terraform" : { + "required_providers" : { + "aws" : { + "source" : "hashicorp/aws" + } + } + }, + "provider" : { + "aws" : [ + { + "region" : "eu-central-1" + } + ] + }, + "resource" : [ + { + "aws_lambda_function" : { + "lambda" : { + "function_name" : "Tapir", + "role" : "${aws_iam_role.lambda_exec.arn}", + "timeout" : 10, + "memory_size" : 256, + "s3_bucket" : "bucket", + "s3_key" : "key", + "runtime" : "java11", + "handler" : "Handler::handleRequest" + } + } + }, + { + "aws_iam_role" : { + "lambda_exec" : { + "name" : "lambda_exec_role", + "assume_role_policy" : "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Effect\":\"Allow\",\"Sid\":\"\"}]}" + } + } + }, + { + "aws_lambda_permission" : { + "api_gateway_permission" : { + "statement_id" : "AllowAPIGatewayInvoke", + "action" : "lambda:InvokeFunction", + "function_name" : "${aws_lambda_function.lambda.function_name}", + "principal" : "apigateway.amazonaws.com", + "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + } + } + }, + { + "aws_api_gateway_rest_api" : { + "TapirApiGateway" : { + "name" : "TapirApiGateway", + "description" : "Serverless Application" + } + } + }, + { + "aws_api_gateway_deployment" : { + "TapirApiGateway" : { + "depends_on" : [ + "aws_api_gateway_integration.AnyRoot" + ], + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "stage_name" : "test" + } + } + }, + { + "aws_api_gateway_method" : { + "AnyRoot" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", + "http_method" : "ANY", + "authorization" : "NONE" + } + } + }, + { + "aws_api_gateway_integration" : { + "AnyRoot" : { + "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", + "resource_id" : "${aws_api_gateway_method.AnyRoot.resource_id}", + "http_method" : "${aws_api_gateway_method.AnyRoot.http_method}", + "integration_http_method" : "POST", + "type" : "AWS_PROXY", + "uri" : "${aws_lambda_function.lambda.invoke_arn}" + } + } + } + ], + "output" : { + "base_url" : { + "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + } + } +} \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala index b9683cda9e..99a99747be 100644 --- a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala +++ b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala @@ -20,6 +20,16 @@ class VerifyTerraformTemplateTest extends AnyFunSuite with Matchers { AwsTerraformInterpreter.toTerraformConfig(List.empty).toJson() } + test("should match expected json root endpoint") { + val ep = endpoint + + val expectedJson = load("root_endpoint.json") + val actualJson = AwsTerraformInterpreter.toTerraformConfig(List(ep)).toJson() + println(actualJson) + + expectedJson shouldBe noIndentation(actualJson) + } + test("should match expected json simple endpoint") { val ep = endpoint.get.in("hello" / "world") @@ -50,9 +60,10 @@ class VerifyTerraformTemplateTest extends AnyFunSuite with Matchers { endpoint.post.in("accounts" / path[String]("id") / "transactions") ) + val expectedJson = load("endpoints_common_paths.json") val actualJson = AwsTerraformInterpreter.toTerraformConfig(eps).toJson() - println(actualJson) + expectedJson shouldBe noIndentation(actualJson) } } From ab49f54991158c488417a710aa2127186ad63354 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 25 May 2021 12:10:15 +0200 Subject: [PATCH 25/35] clean up --- .../aws/terraform/ApiResourceTree.scala | 37 +------------------ .../aws/terraform/TerraformExample.scala | 29 --------------- .../aws/terraform/TerraformResource.scala | 2 +- .../serverless/aws/terraform/model.scala | 2 +- 4 files changed, 3 insertions(+), 67 deletions(-) delete mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala index 36d7cbede0..a03d59f4f4 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala @@ -4,8 +4,6 @@ import sttp.model.Method import sttp.tapir.serverless.aws.terraform.EndpointsToTerraformConfig.IdMethodEndpointInput import sttp.tapir.{Codec, EndpointIO, EndpointInput} -import java.util.UUID - private[terraform] object ApiResourceTree { val RootPathComponent: PathComponent = PathComponent("root", Method("ANY"), Left(EndpointInput.FixedPath("/", Codec.idPlain(), EndpointIO.Info.empty))) @@ -40,41 +38,8 @@ private[terraform] object ApiResourceTree { ResourceTree(RootPathComponent, paths0.map(collectChildren(1, _))) } - private def distinctBy[D](value: PathComponent => D)(pcs: Seq[PathComponent]): Seq[PathComponent] = + private def distinctBy[V](value: PathComponent => V)(pcs: Seq[PathComponent]): Seq[PathComponent] = pcs.groupBy(value).flatMap { case (_, pcs) => pcs.headOption }.toSeq } case class ResourceTree(path: PathComponent, children: Seq[ResourceTree]) - -object Dupa extends App { - def show(r: ResourceTree): Unit = { - def showResource(nest: Int = 1, r: ResourceTree): Unit = { - if (r.children.nonEmpty) { - r.children.foreach { c => - println(" " * nest + c.path.name + " " + c.path.method.method) - showResource(nest + 1, c) - } - } - } - println(r.path.name + " " + r.path.method.method) - showResource(1, r) - } - - import sttp.tapir._ - import sttp.tapir.internal._ - - val eps = List( - endpoint.get.in("accounts" / path[String]("id")), - endpoint.post.in("accounts"), - endpoint.get.in("accounts" / path[String]("id") / "transactions"), - endpoint.post.in("accounts" / path[String]("id") / "transactions") - ) - - val epsBasicInputs: Seq[(Endpoint[_, _, _, _], Vector[IdMethodEndpointInput])] = eps.map { ep => - ep -> ep.input.asVectorOfBasicInputs().map(input => (UUID.randomUUID().toString, ep.httpMethod.getOrElse(Method("ANY")), input)) - } - - val tree = ApiResourceTree(epsBasicInputs.map(_._2)) - - show(tree) -} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala deleted file mode 100644 index 58c159d86a..0000000000 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformExample.scala +++ /dev/null @@ -1,29 +0,0 @@ -package sttp.tapir.serverless.aws.terraform - -import io.circe.Printer -import io.circe.syntax._ -import sttp.tapir._ -import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ - -import java.nio.charset.StandardCharsets.UTF_8 -import java.nio.file.{Files, Paths} - -object TerraformExample extends App { - - val eps = List( - endpoint - ) - - implicit val options: AwsTerraformOptions = AwsTerraformOptions( - awsRegion = "eu-central-1", - functionName = "Tapir", - apiGatewayName = "TapirApiGateway", - functionSource = S3Source("terraform-example-kubinio", "v1.0.0/example.zip", "nodejs10.x", "main.handler") - ) - - val methods: AwsTerraformApiGateway = EndpointsToTerraformConfig(eps) - - val apiGatewayJson = Printer.spaces2.print(methods.asJson) - - Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.tf.json"), apiGatewayJson.getBytes(UTF_8)) -} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala index fb84520146..1a30973a4c 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala @@ -10,7 +10,7 @@ sealed trait TerraformResource { } private[terraform] object TerraformResource { - val TapirApiGateway = "TapirApiGateway" + val TapirApiGateway = "TapirApiGateway" // main resource name def terraformResource[R](`type`: String, name: String, encoded: Json): Json = Json.fromFields(Seq(`type` -> Json.fromFields(Seq(name -> encoded)))) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala index ab13cc06b2..8b81a4b7e1 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -7,7 +7,7 @@ import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ case class AwsTerraformApiGateway(resourceTree: ResourceTree, methods: Seq[AwsTerraformApiGatewayMethod]) { def toJson()(implicit options: AwsTerraformOptions): String = { val gateway = this - Printer.spaces2.print(gateway.asJson(AwsTerraformEncoders.encoderAwsTerraformApiGateway)) + Printer.spaces2.print(gateway.asJson) } } From 5d9aa9104f7bd2f3730f69a4f32572ad885066dd Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 25 May 2021 12:15:33 +0200 Subject: [PATCH 26/35] --update for sam install --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d5f1881f6..a312ffdff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: | wget -q https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation - sudo ./sam-installation/install + sudo ./sam-installation/install --update sam --version - name: Compile run: sbt -v compile compileDocumentation From 61798fc5a0b3590b86297381a39f6e5f34f6b260 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 25 May 2021 12:47:19 +0200 Subject: [PATCH 27/35] match fix --- .../tapir/serverless/aws/sam/AwsSamOptions.scala | 2 +- .../scala/sttp/tapir/serverless/aws/sam/model.scala | 8 +++----- .../serverless/aws/terraform/ApiResourceTree.scala | 8 ++++---- .../aws/terraform/EndpointsToTerraformConfig.scala | 13 ++++++++----- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala index da5a38bc74..312eed00e3 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/AwsSamOptions.scala @@ -9,6 +9,6 @@ case class AwsSamOptions( memorySize: Int = 256 ) -trait FunctionSource +sealed trait FunctionSource case class ImageSource(imageUri: String) extends FunctionSource case class CodeSource(runtime: String, codeUri: String, handler: String) extends FunctionSource diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala index f9a3ebef87..0fe8b86176 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala @@ -3,8 +3,6 @@ package sttp.tapir.serverless.aws.sam import io.circe.syntax._ import sttp.tapir.serverless.aws.sam.AwsSamTemplateEncoders._ -import scala.collection.immutable.ListMap - case class SamTemplate( AWSTemplateFormatVersion: String = "2010-09-09", Transform: String = "AWS::Serverless-2016-10-31", @@ -17,15 +15,15 @@ case class SamTemplate( } } -trait Resource { +sealed trait Resource { def Properties: Properties } case class FunctionResource(Properties: Properties) extends Resource case class HttpResource(Properties: HttpProperties) extends Resource -trait Properties +sealed trait Properties -trait FunctionProperties { +sealed trait FunctionProperties { val Timeout: Long val MemorySize: Int val Events: Map[String, FunctionHttpApiEvent] diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala index a03d59f4f4..ca05a48bdb 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala @@ -9,10 +9,10 @@ private[terraform] object ApiResourceTree { PathComponent("root", Method("ANY"), Left(EndpointInput.FixedPath("/", Codec.idPlain(), EndpointIO.Info.empty))) private val pathMatches: (PathComponent, PathComponent) => Boolean = (a, b) => { - (a.component, b.component) match { - case (Left(fp1), Left(fp2)) => fp1.s == fp2.s && a.method == b.method - case (Right(pc1), Right(pc2)) => pc1.name == pc2.name && a.method == b.method - case _ => false + (a, b) match { + case (PathComponent(_, m1, Left(fp1)), PathComponent(_, m2, Left(fp2))) => fp1.s == fp2.s && m1 == m2 + case (PathComponent(_, m1, Right(pc1)), PathComponent(_, m2, Right(pc2))) => pc1.name == pc2.name && m1 == m2 + case _ => false } } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala index d476258601..48ee6332f9 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala @@ -2,6 +2,7 @@ package sttp.tapir.serverless.aws.terraform import sttp.model.Method import sttp.tapir.internal._ +import sttp.tapir.serverless.aws.terraform.PathComponent.DefaultParamName import sttp.tapir.{Endpoint, EndpointInput, _} import java.util.UUID @@ -28,7 +29,7 @@ private[terraform] object EndpointsToTerraformConfig { input match { case (id, m, fp @ EndpointInput.FixedPath(p, _, _)) => (acc :+ PathComponent(id, m, Left(fp)) -> p, c) case (id, m, pc @ EndpointInput.PathCapture(name, _, _)) => - (acc :+ PathComponent(id, m, Right(pc)) -> name.getOrElse(s"param$c"), if (name.isEmpty) c + 1 else c) + (acc :+ PathComponent(id, m, Right(pc)) -> name.getOrElse(s"$DefaultParamName$c"), if (name.isEmpty) c + 1 else c) case _ => (acc, c) } } @@ -48,9 +49,7 @@ private[terraform] object EndpointsToTerraformConfig { case (_, _, EndpointIO.Header(name, codec, _)) => s"$headerPrefix$name" -> !codec.schema.isOptional case (_, _, EndpointIO.FixedHeader(header, codec, _)) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional case (_, _, EndpointInput.Query(name, codec, _)) => s"$queryPrefix$name" -> !codec.schema.isOptional - } ++ pathComponents.collect { case (_ @PathComponent(_, _, Right(pc)), _) => - s"$pathPrefix${pc.name.getOrElse("param")}" -> !pc.codec.schema.isOptional - } + } ++ pathComponents.collect { case (c @ PathComponent(_, _, Right(pc)), _) => s"$pathPrefix${c.name}" -> !pc.codec.schema.isOptional } AwsTerraformApiGatewayMethod( name, @@ -68,6 +67,10 @@ private[terraform] object EndpointsToTerraformConfig { case class PathComponent(id: String, method: Method, component: Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]]) { def name: String = component match { case Left(fp) => fp.s - case Right(pc) => pc.name.getOrElse("param") + case Right(pc) => pc.name.getOrElse(DefaultParamName) } } + +object PathComponent { + val DefaultParamName = "param" +} From b63861ae5019ac6b7987ede9a1dc3dcbfe69681d Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Wed, 26 May 2021 13:15:55 +0200 Subject: [PATCH 28/35] update terraform resources to v2 --- build.sbt | 28 +++- doc/index.md | 1 + project/Versions.scala | 2 +- .../server/akkahttp/AkkaHttpServerTest.scala | 16 +- .../finatra/cats/FinatraServerCatsTests.scala | 6 +- .../server/finatra/FinatraServerTest.scala | 14 +- .../server/http4s/Http4sServerTest.scala | 22 +-- .../tapir/server/play/PlayServerTest.scala | 14 +- ...er.scala => DefaultCreateServerTest.scala} | 8 +- .../tests/ServerAuthenticationTests.scala | 4 +- .../tapir/server/tests/ServerBasicTests.scala | 4 +- ...s.scala => ServerFileMultipartTests.scala} | 6 +- .../server/tests/ServerMetricsTest.scala | 4 +- .../server/tests/ServerStreamingTests.scala | 4 +- .../server/tests/ServerWebSocketTests.scala | 4 +- .../server/vertx/CatsVertxServerTest.scala | 14 +- .../vertx/VertxBlockingServerTest.scala | 12 +- .../tapir/server/vertx/VertxServerTest.scala | 16 +- .../server/vertx/ZioVertxServerTest.scala | 18 +-- .../aws/examples/LambdaApiExample.scala | 26 ++- .../aws/examples/TerraformConfigExample.scala | 8 +- .../aws/lambda/tests/LambdaHandler.scala | 2 +- .../AwsLambdaCreateServerStubTest.scala} | 11 +- .../aws/lambda}/AwsLambdaStubHttpTest.scala | 7 +- .../aws/terraform/ApiResourceTree.scala | 45 ------ .../aws/terraform/AwsTerraformEncoders.scala | 47 ++---- .../aws/terraform/AwsTerraformOptions.scala | 2 + .../EndpointsToTerraformConfig.scala | 49 +++--- .../aws/terraform/TerraformResource.scala | 92 +++++------ .../serverless/aws/terraform/model.scala | 7 +- .../test/resources/endpoint_with_params.json | 73 +++------ .../resources/endpoints_common_paths.json | 151 +++++++----------- .../src/test/resources/root_endpoint.json | 48 +++--- .../src/test/resources/simple_endpoint.json | 58 +++---- .../VerifyTerraformTemplateTest.scala | 1 - 35 files changed, 338 insertions(+), 486 deletions(-) rename server/tests/src/main/scala/sttp/tapir/server/tests/{CreateTestServer.scala => DefaultCreateServerTest.scala} (94%) rename server/tests/src/main/scala/sttp/tapir/server/tests/{ServerFileMutltipartTests.scala => ServerFileMultipartTests.scala} (96%) rename serverless/aws/{lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala => lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala} (93%) rename serverless/aws/{lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests => lambda/src/test/scala/sttp/tapir/serverless/aws/lambda}/AwsLambdaStubHttpTest.scala (87%) delete mode 100644 serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala diff --git a/build.sbt b/build.sbt index 276a4850ef..665a3b1ac9 100644 --- a/build.sbt +++ b/build.sbt @@ -896,15 +896,18 @@ lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambd ) ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core, cats, circeJson, awsSam) + .dependsOn(core, cats, circeJson, awsSam, sttpStubServer % "test", tests % "test", serverTests) lazy val sam = Process("sam local start-api --warm-containers EAGER").run() +// integration tests for lambda interpreter +// it's a separate project since it needs a fat jar with lambda code which cannot be build from tests sources +// runs sam local cmd line tool to start AWS Api Gateway with lambda proxy lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/lambda-tests")) .settings(commonJvmSettings) .settings( name := "tapir-aws-lambda-tests", - libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.lambdaInterface, + libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.awsLambdaInterface, assembly / assemblyJarName := "tapir-aws-lambda-tests.jar", assembly / test := {}, // no tests before building jar assembly / assemblyMergeStrategy := { @@ -912,10 +915,14 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ case _ @("scala/annotation/nowarn.class" | "scala/annotation/nowarn$.class") => MergeStrategy.first case x => (assembly / assemblyMergeStrategy).value(x) }, - Test / test := (Test / test) - .dependsOn((Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate")) - .dependsOn(assembly) - .value, + Test / test := { + if (scalaVersion.value == scala2_13) + (Test / test) + .dependsOn((Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate")) + .dependsOn(assembly) + .value + else {} // skip tests (run them only once for scala 2.13) + }, Test / testOptions += Tests.Setup(() => { val log = sLog.value val samReady = PollingUtils.poll(30.seconds, 1.second) { @@ -930,7 +937,7 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ Test / parallelExecution := false ) .jvmPlatform(scalaVersions = allScalaVersions) - .dependsOn(core, cats, circeJson, awsLambda, awsSam, sttpStubServer, tests, serverTests) + .dependsOn(core, cats, circeJson, awsLambda, awsSam, tests) lazy val awsSam: ProjectMatrix = (projectMatrix in file("serverless/aws/sam")) .settings(commonJvmSettings) @@ -963,11 +970,16 @@ lazy val runSamExample = taskKey[Unit]("runs aws lambda example on sam local") lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) .settings(commonJvmSettings) .settings( - libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.lambdaInterface + libraryDependencies += "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % Versions.awsLambdaInterface ) .settings( name := "tapir-aws-examples", assembly / assemblyJarName := "tapir-aws-examples.jar", + assembly / assemblyMergeStrategy := { + case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first + case _ @("scala/annotation/nowarn.class" | "scala/annotation/nowarn$.class") => MergeStrategy.first + case x => (assembly / assemblyMergeStrategy).value(x) + }, runSamExample := { val log = sLog.value (Compile / runMain).toTask(" sttp.tapir.serverless.aws.examples.SamTemplateExample$").value diff --git a/doc/index.md b/doc/index.md index d7fb1f125c..a27d9992b6 100644 --- a/doc/index.md +++ b/doc/index.md @@ -136,6 +136,7 @@ Development and maintenance of sttp tapir is sponsored by [SoftwareMill](https:/ :caption: Server interpreters server/akkahttp + server/aws server/http4s server/finatra server/play diff --git a/project/Versions.scala b/project/Versions.scala index a4d4d1db08..e8c6cd086c 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -31,5 +31,5 @@ object Versions { val jwtScala = "5.0.0" val derevo = "0.12.5" val newtype = "0.4.4" - val lambdaInterface = "1.0.0" + val awsLambdaInterface = "1.0.0" } diff --git a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala index 0affa0c410..7ba06ce7e1 100644 --- a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala +++ b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala @@ -17,7 +17,7 @@ import sttp.model.sse.ServerSentEvent import sttp.monad.FutureMonad import sttp.monad.syntax._ import sttp.tapir._ -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} +import sttp.tapir.server.tests.{DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerFileMultipartTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import java.util.UUID @@ -37,7 +37,7 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { implicit val m: FutureMonad = new FutureMonad()(actorSystem.dispatcher) val interpreter = new AkkaHttpTestServerInterpreter()(actorSystem) - val createTestServer = new CreateTestServer(backend, interpreter).asInstanceOf[CreateTestServer[Future, AkkaStreams with WebSockets, Route, AkkaResponseBody]] + val createServerTest = new DefaultCreateServerTest(backend, interpreter).asInstanceOf[DefaultCreateServerTest[Future, AkkaStreams with WebSockets, Route, AkkaResponseBody]] def additionalTests(): List[Test] = List( Test("endpoint nested in a path directive") { @@ -80,14 +80,14 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { } ) - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests(createTestServer).tests() ++ - new ServerWebSocketTests(createTestServer, AkkaStreams) { + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests(createServerTest).tests() ++ + new ServerWebSocketTests(createServerTest, AkkaStreams) { override def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] = Flow.fromFunction(f) }.tests() ++ - new ServerStreamingTests(createTestServer, AkkaStreams).tests() ++ - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerMetricsTest(createTestServer).tests() ++ + new ServerStreamingTests(createServerTest, AkkaStreams).tests() ++ + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerMetricsTest(createServerTest).tests() ++ additionalTests() } } diff --git a/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala b/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala index 86117033c7..c0f2a755d6 100644 --- a/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala +++ b/server/finatra-server/finatra-server-cats/src/test/scala/sttp/tapir/server/finatra/cats/FinatraServerCatsTests.scala @@ -2,7 +2,7 @@ package sttp.tapir.server.finatra.cats import cats.effect.{IO, Resource} import sttp.client3.impl.cats.CatsMonadAsyncError -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, backendResource} +import sttp.tapir.server.tests.{DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerFileMultipartTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} class FinatraServerCatsTests extends TestSuite { @@ -10,10 +10,10 @@ class FinatraServerCatsTests extends TestSuite { implicit val m: CatsMonadAsyncError[IO] = new CatsMonadAsyncError[IO]() val interpreter = new FinatraCatsTestServerInterpreter() - val createTestServer = new CreateTestServer(backend, interpreter) + val createTestServer = new DefaultCreateServerTest(backend, interpreter) new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests(createTestServer).tests() ++ + new ServerFileMultipartTests(createTestServer).tests() ++ new ServerAuthenticationTests(createTestServer).tests() } } diff --git a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala index f6e9f2faf2..3c414ad4a9 100644 --- a/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala +++ b/server/finatra-server/src/test/scala/sttp/tapir/server/finatra/FinatraServerTest.scala @@ -4,10 +4,10 @@ import cats.effect.{IO, Resource} import sttp.monad.MonadError import sttp.tapir.server.finatra.FinatraServerInterpreter.FutureMonadError import sttp.tapir.server.tests.{ - CreateTestServer, + DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, - ServerFileMutltipartTests, + ServerFileMultipartTests, ServerMetricsTest, backendResource } @@ -17,13 +17,13 @@ class FinatraServerTest extends TestSuite { override def tests: Resource[IO, List[Test]] = backendResource.map { backend => val interpreter = new FinatraTestServerInterpreter() - val createTestServer = new CreateTestServer(backend, interpreter) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) implicit val m: MonadError[com.twitter.util.Future] = FutureMonadError - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests(createTestServer).tests() ++ - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerMetricsTest(createTestServer).tests() + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests(createServerTest).tests() ++ + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerMetricsTest(createServerTest).tests() } } diff --git a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala index 02f942ef23..bf51df836f 100644 --- a/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala +++ b/server/http4s-server/src/test/scala/sttp/tapir/server/http4s/Http4sServerTest.scala @@ -14,7 +14,7 @@ import sttp.client3._ import sttp.model.sse.ServerSentEvent import sttp.tapir._ import sttp.tapir.integ.cats.CatsMonadError -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} +import sttp.tapir.server.tests.{DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerFileMultipartTests, ServerMetricsTest, ServerStreamingTests, ServerWebSocketTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import sttp.ws.{WebSocket, WebSocketFrame} @@ -29,7 +29,7 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi implicit val m: CatsMonadError[IO] = new CatsMonadError[IO] val interpreter = new Http4sTestServerInterpreter() - val createTestServer = new CreateTestServer(backend, interpreter) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) def randomUUID = Some(UUID.randomUUID().toString) val sse1 = ServerSentEvent(randomUUID, randomUUID, randomUUID, Some(Random.nextInt(200))) val sse2 = ServerSentEvent(randomUUID, randomUUID, randomUUID, Some(Random.nextInt(200))) @@ -51,7 +51,7 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi } .unsafeRunSync() }, - createTestServer.testServer( + createServerTest.testServer( endpoint.out( webSocketBody[String, CodecFormat.TextPlain, String, CodecFormat.TextPlain] .apply(Fs2Streams[IO]) @@ -67,7 +67,7 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi .send(backend) .map(_.body should matchPattern { case Right(List(WebSocketFrame.Ping(_), WebSocketFrame.Ping(_))) => }) }, - createTestServer.testServer( + createServerTest.testServer( endpoint.out(streamBinaryBody(Fs2Streams[IO])), "streaming should send data according to producer stream rate" )((_: Unit) => @@ -86,7 +86,7 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi case wrongResponse => fail(s"expected to get count of received data chunks, instead got $wrongResponse") }) }, - createTestServer.testServer( + createServerTest.testServer( endpoint.out(serverSentEventsBody[IO]), "Send and receive SSE" )((_: Unit) => IO(Right(fs2.Stream(sse1, sse2)))) { (backend, baseUri) => @@ -104,13 +104,13 @@ class Http4sServerTest[R >: Fs2Streams[IO] with WebSockets] extends TestSuite wi } ) - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests(createTestServer).tests() ++ - new ServerStreamingTests(createTestServer, Fs2Streams[IO]).tests() ++ - new ServerWebSocketTests(createTestServer, Fs2Streams[IO]) { + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests(createServerTest).tests() ++ + new ServerStreamingTests(createServerTest, Fs2Streams[IO]).tests() ++ + new ServerWebSocketTests(createServerTest, Fs2Streams[IO]) { override def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] = in => in.map(f) }.tests() ++ - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerMetricsTest(createTestServer).tests() ++ additionalTests() + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerMetricsTest(createServerTest).tests() ++ additionalTests() } } diff --git a/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala b/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala index 012738c1bb..c031778cd5 100644 --- a/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala +++ b/server/play-server/src/test/scala/sttp/tapir/server/play/PlayServerTest.scala @@ -4,10 +4,10 @@ import akka.actor.ActorSystem import cats.effect.{IO, Resource} import sttp.monad.FutureMonad import sttp.tapir.server.tests.{ - CreateTestServer, + DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, - ServerFileMutltipartTests, + ServerFileMultipartTests, ServerMetricsTest, backendResource } @@ -23,17 +23,17 @@ class PlayServerTest extends TestSuite { implicit val m: FutureMonad = new FutureMonad()(actorSystem.dispatcher) val interpreter = new PlayTestServerInterpreter()(actorSystem) - val createTestServer = new CreateTestServer(backend, interpreter) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) new ServerBasicTests( - createTestServer, + createServerTest, interpreter, multipleValueHeaderSupport = false, inputStreamSupport = false ).tests() ++ - new ServerFileMutltipartTests(createTestServer, multipartInlineHeaderSupport = false).tests() - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerMetricsTest(createTestServer).tests() + new ServerFileMultipartTests(createServerTest, multipartInlineHeaderSupport = false).tests() + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerMetricsTest(createServerTest).tests() } } } diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/CreateTestServer.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala similarity index 94% rename from server/tests/src/main/scala/sttp/tapir/server/tests/CreateTestServer.scala rename to server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala index d6d8340159..ab683c61fa 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/CreateTestServer.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala @@ -15,10 +15,10 @@ import sttp.tapir.server.interceptor.decodefailure.DecodeFailureHandler import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import sttp.tapir.tests._ -class CreateTestServer[F[_], +R, ROUTE, B]( +class DefaultCreateServerTest[F[_], +R, ROUTE, B]( backend: SttpBackend[IO, R], interpreter: TestServerInterpreter[F, R, ROUTE, B] -) extends TestServer[F, R, ROUTE, B] +) extends CreateServerTest[F, R, ROUTE, B] with StrictLogging { override def testServer[I, E, O]( e: Endpoint[I, E, O, R], @@ -63,11 +63,11 @@ class CreateTestServer[F[_], +R, ROUTE, B]( } } -object CreateTestServer { +object DefaultCreateServerTest { type StreamsWithWebsockets = Fs2Streams[IO] with WebSockets } -trait TestServer[F[_], +R, ROUTE, B] { +trait CreateServerTest[F[_], +R, ROUTE, B] { def testServer[I, E, O]( e: Endpoint[I, E, O, R], testNameSuffix: String = "", diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala index 7828e84a9b..6b9da09f6d 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerAuthenticationTests.scala @@ -11,9 +11,9 @@ import sttp.tapir._ import sttp.tapir.model.UsernamePassword import sttp.tapir.tests.Test -class ServerAuthenticationTests[F[_], S, ROUTE, B](createTestServer: TestServer[F, S, ROUTE, B])(implicit m: MonadError[F]) +class ServerAuthenticationTests[F[_], S, ROUTE, B](createServerTest: CreateServerTest[F, S, ROUTE, B])(implicit m: MonadError[F]) extends Matchers { - import createTestServer._ + import createServerTest._ private val Realm = "realm" private val base = endpoint.post.in("secret" / path[Long]("id")).in(query[String]("q")) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala index 13194c78ee..850097082f 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala @@ -31,14 +31,14 @@ import scala.concurrent.Await import scala.concurrent.duration.DurationInt class ServerBasicTests[F[_], ROUTE, B]( - createTestServer: TestServer[F, Any, ROUTE, B], + createServerTest: CreateServerTest[F, Any, ROUTE, B], serverInterpreter: TestServerInterpreter[F, Any, ROUTE, B], multipleValueHeaderSupport: Boolean = true, inputStreamSupport: Boolean = true )(implicit m: MonadError[F] ) { - import createTestServer._ + import createServerTest._ import serverInterpreter._ private val basicStringRequest = basicRequest.response(asStringAlways) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMultipartTests.scala similarity index 96% rename from server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala rename to server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMultipartTests.scala index 1adaa4fa8c..faa88fe7c0 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMutltipartTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerFileMultipartTests.scala @@ -20,11 +20,11 @@ import java.io.File import scala.concurrent.Await import scala.concurrent.duration.DurationInt -class ServerFileMutltipartTests[F[_], ROUTE, B]( - createTestServer: TestServer[F, Any, ROUTE, B], +class ServerFileMultipartTests[F[_], ROUTE, B]( + createServerTest: CreateServerTest[F, Any, ROUTE, B], multipartInlineHeaderSupport: Boolean = true )(implicit m: MonadError[F]) { - import createTestServer._ + import createServerTest._ private val basicStringRequest = basicRequest.response(asStringAlways) private def pureResult[T](t: T): F[T] = m.unit(t) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala index 916bf86753..be8ad4aa96 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerMetricsTest.scala @@ -15,8 +15,8 @@ import sttp.tapir.tests.{Test, _} import java.io.{ByteArrayInputStream, InputStream} import java.util.concurrent.atomic.AtomicInteger -class ServerMetricsTest[F[_], ROUTE, B](createTestServer: TestServer[F, Any, ROUTE, B])(implicit m: MonadError[F]) { - import createTestServer._ +class ServerMetricsTest[F[_], ROUTE, B](createServerTest: CreateServerTest[F, Any, ROUTE, B])(implicit m: MonadError[F]) { + import createServerTest._ def tests(): List[Test] = List( { diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala index 3b6269634b..65f1413be7 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerStreamingTests.scala @@ -8,14 +8,14 @@ import sttp.model.{Header, HeaderNames} import sttp.monad.MonadError import sttp.tapir.tests.{Test, in_stream_out_stream, in_stream_out_stream_with_content_length} -class ServerStreamingTests[F[_], S, ROUTE, B](createTestServer: TestServer[F, S, ROUTE, B], streams: Streams[S])(implicit +class ServerStreamingTests[F[_], S, ROUTE, B](createServerTest: CreateServerTest[F, S, ROUTE, B], streams: Streams[S])(implicit m: MonadError[F] ) { private def pureResult[T](t: T): F[T] = m.unit(t) def tests(): List[Test] = { - import createTestServer._ + import createServerTest._ val penPineapple = "pen pineapple apple pen" diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala index 0e70e661be..591bf92106 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerWebSocketTests.scala @@ -16,12 +16,12 @@ import sttp.tapir.tests.{Fruit, Test} import sttp.ws.{WebSocket, WebSocketFrame} abstract class ServerWebSocketTests[F[_], S <: Streams[S], ROUTE, B]( - createTestServer: TestServer[F, S with WebSockets, ROUTE, B], + createServerTest: CreateServerTest[F, S with WebSockets, ROUTE, B], val streams: S )(implicit m: MonadError[F] ) { - import createTestServer._ + import createServerTest._ private def pureResult[T](t: T): F[T] = m.unit(t) def functionToPipe[A, B](f: A => B): streams.Pipe[A, B] diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala index 7e5e00fc26..ae90da4577 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/CatsVertxServerTest.scala @@ -4,7 +4,7 @@ import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.capabilities.fs2.Fs2Streams import sttp.monad.MonadError -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, ServerStreamingTests, backendResource} +import sttp.tapir.server.tests.{DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerFileMultipartTests, ServerStreamingTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} class CatsVertxServerTest extends TestSuite { @@ -17,15 +17,15 @@ class CatsVertxServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: MonadError[IO] = VertxCatsServerInterpreter.monadError[IO] val interpreter = new CatsVertxTestServerInterpreter(vertx) - val createTestServer = new CreateTestServer(backend, interpreter) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests( - createTestServer, + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests( + createServerTest, multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong ).tests() - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerStreamingTests(createTestServer, Fs2Streams.apply[IO]).tests() + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerStreamingTests(createServerTest, Fs2Streams.apply[IO]).tests() } } } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala index 6e8f9a7640..a42a88e556 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxBlockingServerTest.scala @@ -3,7 +3,7 @@ package sttp.tapir.server.vertx import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.monad.FutureMonad -import sttp.tapir.server.tests.{CreateTestServer, ServerAuthenticationTests, ServerBasicTests, ServerFileMutltipartTests, backendResource} +import sttp.tapir.server.tests.{DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, ServerFileMultipartTests, backendResource} import sttp.tapir.tests.{Test, TestSuite} import scala.concurrent.ExecutionContext @@ -16,14 +16,14 @@ class VertxBlockingServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: FutureMonad = new FutureMonad()(ExecutionContext.global) val interpreter = new VertxTestServerBlockingInterpreter(vertx) - val createTestServer = new CreateTestServer(backend, interpreter) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests( - createTestServer, + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests( + createServerTest, multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong ).tests() ++ - new ServerAuthenticationTests(createTestServer).tests() + new ServerAuthenticationTests(createServerTest).tests() } } } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala index 2051e7c9bb..23e135bc3e 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala @@ -4,10 +4,10 @@ import cats.effect.{IO, Resource} import io.vertx.core.Vertx import sttp.monad.FutureMonad import sttp.tapir.server.tests.{ - CreateTestServer, + DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, - ServerFileMutltipartTests, + ServerFileMultipartTests, ServerMetricsTest, backendResource } @@ -24,15 +24,15 @@ class VertxServerTest extends TestSuite { implicit val m: FutureMonad = new FutureMonad()(ExecutionContext.global) val interpreter = new VertxTestServerInterpreter(vertx) - val createTestServer = new CreateTestServer(backend, interpreter) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests( - createTestServer, + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests( + createServerTest, multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong ).tests() ++ - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerMetricsTest(createTestServer).tests() + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerMetricsTest(createServerTest).tests() } } } diff --git a/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala b/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala index 3ddfdac566..71ba10c074 100644 --- a/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala +++ b/server/vertx/src/test/scala/sttp/tapir/server/vertx/ZioVertxServerTest.scala @@ -6,10 +6,10 @@ import io.vertx.ext.web.{Route, Router, RoutingContext} import sttp.capabilities.zio.ZioStreams import sttp.monad.MonadError import sttp.tapir.server.tests.{ - CreateTestServer, + DefaultCreateServerTest, ServerAuthenticationTests, ServerBasicTests, - ServerFileMutltipartTests, + ServerFileMultipartTests, ServerStreamingTests, backendResource } @@ -28,16 +28,16 @@ class ZioVertxServerTest extends TestSuite { vertxResource.map { implicit vertx => implicit val m: MonadError[Task] = VertxZioServerInterpreter.monadError val interpreter = new ZioVertxTestServerInterpreter(vertx) - val createTestServer = - new CreateTestServer(backend, interpreter).asInstanceOf[CreateTestServer[Task, ZioStreams, Router => Route, RoutingContext => Unit]] + val createServerTest = + new DefaultCreateServerTest(backend, interpreter).asInstanceOf[DefaultCreateServerTest[Task, ZioStreams, Router => Route, RoutingContext => Unit]] - new ServerBasicTests(createTestServer, interpreter).tests() ++ - new ServerFileMutltipartTests( - createTestServer, + new ServerBasicTests(createServerTest, interpreter).tests() ++ + new ServerFileMultipartTests( + createServerTest, multipartInlineHeaderSupport = false // README: doesn't seem supported but I may be wrong ).tests() ++ - new ServerAuthenticationTests(createTestServer).tests() ++ - new ServerStreamingTests(createTestServer, ZioStreams).tests() + new ServerAuthenticationTests(createServerTest).tests() ++ + new ServerStreamingTests(createServerTest, ZioStreams).tests() } } } diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala index 6120b9cb36..681b77c81d 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala @@ -10,7 +10,6 @@ import io.circe.syntax._ import sttp.model.StatusCode import sttp.tapir._ import sttp.tapir.server.ServerEndpoint -import sttp.tapir.serverless.aws.examples.LambdaApiExample.route import sttp.tapir.serverless.aws.lambda._ import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} @@ -22,7 +21,16 @@ import java.nio.charset.StandardCharsets.UTF_8 * Select `awsExamples` project from sbt shell and run `assembly` task to build a fat jar with lambda handler. * Then `runSamExample` to generate `template.yaml` and start up `sam local`. */ -class LambdaApiExample extends RequestStreamHandler { +object LambdaApiExample extends RequestStreamHandler { + + val helloEndpoint: ServerEndpoint[Unit, Unit, String, Any, IO] = endpoint.get + .in("api" / "hello") + .out(stringBody) + .serverLogic { _ => IO.pure(s"Hello!".asRight[Unit]) } + + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) + + val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { @@ -33,7 +41,7 @@ class LambdaApiExample extends RequestStreamHandler { (decode[AwsRequest](json) match { /** Process request using interpreted route */ case Right(awsRequest) => route(awsRequest) - case Left(_) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, "")) + case Left(ex) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, ex.getMessage)) }).map { awsRes => /** Write response to output */ val writer = new BufferedWriter(new OutputStreamWriter(output, UTF_8)) @@ -42,15 +50,3 @@ class LambdaApiExample extends RequestStreamHandler { }.unsafeRunSync() } } - -object LambdaApiExample { - - val helloEndpoint: ServerEndpoint[Unit, Unit, String, Any, IO] = endpoint.get - .in("api" / "hello") - .out(stringBody) - .serverLogic { _ => IO.pure("Hello!".asRight[Unit]) } - - implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) - - val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) -} diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala index 79c9c0b8f8..466f38538b 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala @@ -8,22 +8,24 @@ import sttp.tapir.serverless.aws.terraform.{AwsTerraformApiGateway, AwsTerraform import java.nio.charset.StandardCharsets.UTF_8 import java.nio.file.{Files, Paths} +import scala.concurrent.duration.DurationInt /** Before running the actual example we need to interpret our api as Terraform resources */ object TerraformConfigExample extends App { - val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString - implicit val terraformOptions: AwsTerraformOptions = AwsTerraformOptions( "eu-central-1", functionName = "PersonsFunction", apiGatewayName = "PersonsApiGateway", + autoDeploy = true, functionSource = S3Source( "terraform-example-kubinio", "v1.0.0/tapir-aws-examples.jar", "java11", "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest" - ) + ), + timeout = 30.seconds, + memorySize = 1024 ) val apiGateway: AwsTerraformApiGateway = AwsTerraformInterpreter.toTerraformConfig(helloEndpoint) diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 82e63835ea..91772b5f3c 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -24,7 +24,7 @@ object LambdaHandler extends RequestStreamHandler { (decode[AwsRequest](json) match { case Right(awsRequest) => route(awsRequest) - case Left(_) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, "")) + case Left(_) => IO.pure(AwsResponse(isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, Map.empty, "")) }).map { awsRes => val writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)) writer.write(Printer.noSpaces.print(awsRes.asJson)) diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala similarity index 93% rename from serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala rename to serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala index 4317d714d8..c46fc5b3e6 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubTestServer.scala +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala @@ -1,4 +1,4 @@ -package sttp.tapir.serverless.aws.lambda.tests +package sttp.tapir.serverless.aws.lambda import cats.data.NonEmptyList import cats.effect.IO @@ -12,12 +12,11 @@ import sttp.tapir.integ.cats.CatsMonadError import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor -import sttp.tapir.server.tests.TestServer -import sttp.tapir.serverless.aws.lambda._ -import sttp.tapir.serverless.aws.lambda.tests.AwsLambdaStubTestServer._ +import sttp.tapir.server.tests.CreateServerTest +import sttp.tapir.serverless.aws.lambda.AwsLambdaCreateServerStubTest._ import sttp.tapir.tests.Test -class AwsLambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { +class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], String] { override def testServer[I, E, O]( e: Endpoint[I, E, O, Any], @@ -62,7 +61,7 @@ class AwsLambdaStubTestServer extends TestServer[IO, Any, Route[IO], String] { } } -object AwsLambdaStubTestServer { +object AwsLambdaCreateServerStubTest { implicit val catsMonadIO: CatsMonadError[IO] = new CatsMonadError[IO] def sttpToAwsRequest(request: Request[_, _]): AwsRequest = { diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala similarity index 87% rename from serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala rename to serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala index 2cea9bfa31..1e13c599cd 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaStubHttpTest.scala +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala @@ -1,4 +1,4 @@ -package sttp.tapir.serverless.aws.lambda.tests +package sttp.tapir.serverless.aws.lambda import cats.data.NonEmptyList import cats.effect.{IO, Resource} @@ -7,8 +7,7 @@ import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.interceptor.decodefailure.{DecodeFailureHandler, DefaultDecodeFailureHandler} import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import sttp.tapir.server.tests.{ServerBasicTests, ServerMetricsTest, TestServerInterpreter} -import sttp.tapir.serverless.aws.lambda.tests.AwsLambdaStubTestServer._ -import sttp.tapir.serverless.aws.lambda.{AwsServerInterpreter, AwsServerOptions, Route} +import sttp.tapir.serverless.aws.lambda.AwsLambdaCreateServerStubTest.catsMonadIO import sttp.tapir.tests.{Port, Test, TestSuite} import scala.reflect.ClassTag @@ -16,7 +15,7 @@ import scala.reflect.ClassTag class AwsLambdaStubHttpTest extends TestSuite { override def tests: Resource[IO, List[Test]] = Resource.eval( IO.pure { - val createTestServer = new AwsLambdaStubTestServer + val createTestServer = new AwsLambdaCreateServerStubTest new ServerBasicTests(createTestServer, AwsLambdaStubHttpTest.testServerInterpreter)(catsMonadIO).tests() ++ new ServerMetricsTest(createTestServer).tests() } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala deleted file mode 100644 index ca05a48bdb..0000000000 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/ApiResourceTree.scala +++ /dev/null @@ -1,45 +0,0 @@ -package sttp.tapir.serverless.aws.terraform - -import sttp.model.Method -import sttp.tapir.serverless.aws.terraform.EndpointsToTerraformConfig.IdMethodEndpointInput -import sttp.tapir.{Codec, EndpointIO, EndpointInput} - -private[terraform] object ApiResourceTree { - val RootPathComponent: PathComponent = - PathComponent("root", Method("ANY"), Left(EndpointInput.FixedPath("/", Codec.idPlain(), EndpointIO.Info.empty))) - - private val pathMatches: (PathComponent, PathComponent) => Boolean = (a, b) => { - (a, b) match { - case (PathComponent(_, m1, Left(fp1)), PathComponent(_, m2, Left(fp2))) => fp1.s == fp2.s && m1 == m2 - case (PathComponent(_, m1, Right(pc1)), PathComponent(_, m2, Right(pc2))) => pc1.name == pc2.name && m1 == m2 - case _ => false - } - } - - def apply(basicInputs: Seq[Vector[IdMethodEndpointInput]]): ResourceTree = { - - val endpointPathComponents: Seq[Seq[PathComponent]] = basicInputs.map(_.collect { - case (id, m, fp @ EndpointInput.FixedPath(_, _, _)) => PathComponent(id, m, Left(fp)) - case (id, m, pc @ EndpointInput.PathCapture(_, _, _)) => PathComponent(id, m, Right(pc)) - }) - - def collectChildren(level: Int, path: PathComponent): ResourceTree = { - val children: Seq[PathComponent] = distinctBy(_.name) { - endpointPathComponents - .filter(pcs => pcs.lift(level).isDefined && pathMatches(pcs(level - 1), path)) - .map(_(level)) - } - if (children.isEmpty) ResourceTree(path, Seq.empty) - else ResourceTree(path, children.map(collectChildren(level + 1, _))) - } - - val paths0: Seq[PathComponent] = distinctBy(pc => (pc.method, pc.name))(endpointPathComponents.flatMap(_.headOption)) - - ResourceTree(RootPathComponent, paths0.map(collectChildren(1, _))) - } - - private def distinctBy[V](value: PathComponent => V)(pcs: Seq[PathComponent]): Seq[PathComponent] = - pcs.groupBy(value).flatMap { case (_, pcs) => pcs.headOption }.toSeq -} - -case class ResourceTree(path: PathComponent, children: Seq[ResourceTree]) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala index 58435c7d4b..17b644231f 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformEncoders.scala @@ -14,39 +14,16 @@ object AwsTerraformEncoders { Seq("required_providers" -> Json.fromFields(Seq("aws" -> Json.fromFields(Seq("source" -> Json.fromString("hashicorp/aws")))))) ) - def toTerraformResources(parent: String, tree: ResourceTree): Seq[(String, AwsApiGatewayResource)] = { - val name = tree.path.name - val resourceName = if (parent == TapirApiGateway) name else s"$parent-$name" - val apiGatewayResource = - AwsApiGatewayResource(resourceName, parentId = parent, tree.path.component.map(_ => s"{$name}").getOrElse(name)) - - if (tree.children.isEmpty) - Seq(tree.path.id -> apiGatewayResource) - else - (tree.path.id -> apiGatewayResource) +: tree.children.flatMap(child => toTerraformResources(parent = resourceName, child)) + val integrations: Seq[TerraformResource] = gateway.routes.flatMap { m => + val integration = AwsApiGatewayV2Integration(m.name) + val route = AwsApiGatewayV2Route(m.name, s"${m.httpMethod.method} /${m.path}", m.name) + Seq(integration, route) } - val pathResources: Seq[(String, AwsApiGatewayResource)] = - gateway.resourceTree.children.flatMap(toTerraformResources(TapirApiGateway, _)) - - val methodResources: Seq[TerraformResource] = - gateway.methods - .flatMap { m => - val resourceId = - pathResources - .find { case (id, _) => m.paths.lastOption.map(_.id).getOrElse(TapirApiGateway) == id } - .map { case (_, res) => res.name } - .getOrElse(TapirApiGateway) - Seq( - AwsApiGatewayMethod(m.name, resourceId, m.httpMethod.method, m.requestParameters), - AwsApiGatewayIntegration(m.name) - ) - } - val output = Json.fromFields( Seq( "base_url" -> Json.fromFields( - Seq("value" -> Json.fromString(s"$${aws_api_gateway_deployment.$TapirApiGateway.invoke_url}")) + Seq("value" -> Json.fromString(s"$${aws_apigatewayv2_api.$TapirApiGateway.api_endpoint}")) ) ) ) @@ -60,16 +37,10 @@ object AwsTerraformEncoders { AwsLambdaFunction(options.functionName, options.timeout, options.memorySize, options.functionSource).json(), AwsIamRole(options.assumeRolePolicy.noSpaces).json(), AwsLambdaPermission.json(), - AwsApiGatewayRestApi(options.apiGatewayName, options.apiGatewayDescription).json(), - AwsApiGatewayDeployment(methodResources.collect { case i @ AwsApiGatewayIntegration(_) => i.name }).json() - ) - ++ pathResources - .groupBy { case (_, res) => res.name } - .flatMap { case (_, res) => res.headOption } - .toSeq - .sortBy { case (_, res) => res.name.length } - .map { case (_, res) => res.json() } - ++ methodResources.map(_.json()) + AwsApiGatewayV2Api(options.apiGatewayName, options.apiGatewayDescription).json(), + AwsApiGatewayV2Deployment(integrations.collect { case i @ AwsApiGatewayV2Integration(_) => i.name }).json(), + AwsApiGatewayV2Stage(options.apiGatewayStage, options.autoDeploy).json() + ) ++ integrations.map(_.json()) ), "output" -> output ) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala index 0b2a4192df..6541eb2190 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/AwsTerraformOptions.scala @@ -11,6 +11,8 @@ case class AwsTerraformOptions( functionName: String, apiGatewayName: String, apiGatewayDescription: String = "Serverless Application", + apiGatewayStage: String = "$default", + autoDeploy: Boolean = false, assumeRolePolicy: Json = lambdaDefaultAssumeRolePolicy, functionSource: FunctionSource, timeout: FiniteDuration = 10.seconds, diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala index 48ee6332f9..8487563434 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala @@ -5,8 +5,6 @@ import sttp.tapir.internal._ import sttp.tapir.serverless.aws.terraform.PathComponent.DefaultParamName import sttp.tapir.{Endpoint, EndpointInput, _} -import java.util.UUID - private[terraform] object EndpointsToTerraformConfig { type IdMethodEndpointInput = (String, Method, EndpointInput.Basic[_]) @@ -15,21 +13,18 @@ private[terraform] object EndpointsToTerraformConfig { private val pathPrefix = "method.request.path." def apply(eps: List[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): AwsTerraformApiGateway = { - val epsBasicInputs: Seq[(Endpoint[_, _, _, _], Vector[IdMethodEndpointInput])] = eps.map { ep => - ep -> ep.input.asVectorOfBasicInputs().map(input => (UUID.randomUUID().toString, ep.httpMethod.getOrElse(Method("ANY")), input)) - } - val resourceTree = ApiResourceTree(epsBasicInputs.map { case (_, bi) => bi }) - - val methods: Seq[AwsTerraformApiGatewayMethod] = epsBasicInputs.map { case (endpoint, bi) => + val methods: Seq[AwsApiGatewayRoute] = eps.map { endpoint => val method = endpoint.httpMethod.getOrElse(Method("ANY")) - val pathComponents: Seq[(PathComponent, String)] = bi - .foldLeft((Seq.empty[(PathComponent, String)], 0)) { case ((acc, c), input) => + val basicInputs = endpoint.input.asVectorOfBasicInputs() + + val pathComponents: Seq[(Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]], String)] = basicInputs + .foldLeft((Seq.empty[(Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]], String)], 0)) { case ((acc, c), input) => input match { - case (id, m, fp @ EndpointInput.FixedPath(p, _, _)) => (acc :+ PathComponent(id, m, Left(fp)) -> p, c) - case (id, m, pc @ EndpointInput.PathCapture(name, _, _)) => - (acc :+ PathComponent(id, m, Right(pc)) -> name.getOrElse(s"$DefaultParamName$c"), if (name.isEmpty) c + 1 else c) + case fp @ EndpointInput.FixedPath(p, _, _) => (acc :+ Left(fp) -> p, c) + case pc @ EndpointInput.PathCapture(name, _, _) => + (acc :+ Right(pc) -> name.getOrElse(s"$DefaultParamName$c"), if (name.isEmpty) c + 1 else c) case _ => (acc, c) } } @@ -37,37 +32,29 @@ private[terraform] object EndpointsToTerraformConfig { val path = pathComponents .map { - case (PathComponent(_, _, Left(_)), p) => p - case (PathComponent(_, _, Right(_)), p) => s"{$p}" + case (Left(_), p) => p + case (Right(_), p) => s"{$p}" } .mkString("/") val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map { case (_, name) => name } val name = s"${method.method.toLowerCase.capitalize}${nameComponents.map(_.toLowerCase.capitalize).mkString}" - val requestParameters: Seq[(String, Boolean)] = bi.collect { - case (_, _, EndpointIO.Header(name, codec, _)) => s"$headerPrefix$name" -> !codec.schema.isOptional - case (_, _, EndpointIO.FixedHeader(header, codec, _)) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional - case (_, _, EndpointInput.Query(name, codec, _)) => s"$queryPrefix$name" -> !codec.schema.isOptional - } ++ pathComponents.collect { case (c @ PathComponent(_, _, Right(pc)), _) => s"$pathPrefix${c.name}" -> !pc.codec.schema.isOptional } + val requestParameters: Seq[(String, Boolean)] = basicInputs.collect { + case EndpointIO.Header(name, codec, _) => s"$headerPrefix$name" -> !codec.schema.isOptional + case EndpointIO.FixedHeader(header, codec, _) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional + case EndpointInput.Query(name, codec, _) => s"$queryPrefix$name" -> !codec.schema.isOptional + } ++ pathComponents.collect { case c @ (Right(pc), name) => s"$pathPrefix${name}" -> !pc.codec.schema.isOptional } - AwsTerraformApiGatewayMethod( + AwsApiGatewayRoute( name, path, method, - pathComponents.map { case (pc, _) => pc }, - requestParameters + Seq.empty ) } - AwsTerraformApiGateway(resourceTree, methods) - } -} - -case class PathComponent(id: String, method: Method, component: Either[EndpointInput.FixedPath[_], EndpointInput.PathCapture[_]]) { - def name: String = component match { - case Left(fp) => fp.s - case Right(pc) => pc.name.getOrElse(DefaultParamName) + AwsTerraformApiGateway(methods) } } diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala index 1a30973a4c..fcaee84b30 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/TerraformResource.scala @@ -72,101 +72,89 @@ case object AwsLambdaPermission extends TerraformResource { "action" -> Json.fromString("lambda:InvokeFunction"), "function_name" -> Json.fromString(s"$${aws_lambda_function.lambda.function_name}"), "principal" -> Json.fromString("apigateway.amazonaws.com"), - "source_arn" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.execution_arn}/*/*") + "source_arn" -> Json.fromString(s"$${aws_apigatewayv2_api.$TapirApiGateway.execution_arn}/*/*") ) ) ) } -case class AwsApiGatewayRestApi(name: String, description: String) extends TerraformResource { +case class AwsApiGatewayV2Api(name: String, description: String) extends TerraformResource { override def json(): Json = { terraformResource( - "aws_api_gateway_rest_api", + "aws_apigatewayv2_api", TapirApiGateway, Json.fromFields( Seq( "name" -> Json.fromString(name), - "description" -> Json.fromString(description) + "description" -> Json.fromString(description), + "protocol_type" -> Json.fromString("HTTP") ) ) ) } } -case class AwsApiGatewayResource(name: String, parentId: String, pathPart: String) extends TerraformResource { - override def json(): Json = - terraformResource( - "aws_api_gateway_resource", - name, - Json.fromFields( - Seq( - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), - "parent_id" -> Json.fromString( - if (parentId == TapirApiGateway) s"$${aws_api_gateway_rest_api.$TapirApiGateway.root_resource_id}" - else s"$${aws_api_gateway_resource.$parentId.id}" - ), - "path_part" -> Json.fromString(pathPart) - ) - ) - ) -} - -case class AwsApiGatewayMethod( +case class AwsApiGatewayV2Route( name: String, - resourceId: String, - httpMethod: String, - requestParameters: Seq[(String, Boolean)] + routeKey: String, // "METHOD PATH" + integration: String ) extends TerraformResource { override def json(): Json = terraformResource( - "aws_api_gateway_method", + "aws_apigatewayv2_route", name, Json.fromFields( Seq( - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), - "resource_id" -> Json.fromString( - if (resourceId == TapirApiGateway) s"$${aws_api_gateway_rest_api.$TapirApiGateway.root_resource_id}" - else s"$${aws_api_gateway_resource.$resourceId.id}" - ), - "http_method" -> Json.fromString(httpMethod), - "authorization" -> Json.fromString("NONE") - ) ++ (if (requestParameters.nonEmpty) - Seq("request_parameters" -> Json.fromFields(requestParameters.map { case (name, required) => - name -> Json.fromBoolean(required) - })) - else Seq.empty) + "api_id" -> Json.fromString(s"$${aws_apigatewayv2_api.$TapirApiGateway.id}"), + "route_key" -> Json.fromString(routeKey), + "authorization_type" -> Json.fromString("NONE"), + "target" -> Json.fromString(s"integrations/$${aws_apigatewayv2_integration.$integration.id}") + ) ) ) } -case class AwsApiGatewayIntegration(name: String) extends TerraformResource { +case class AwsApiGatewayV2Integration(name: String) extends TerraformResource { override def json(): Json = terraformResource( - "aws_api_gateway_integration", + "aws_apigatewayv2_integration", name, Json.fromFields( Seq( - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), - "resource_id" -> Json.fromString(s"$${aws_api_gateway_method.$name.resource_id}"), - "http_method" -> Json.fromString(s"$${aws_api_gateway_method.$name.http_method}"), - "integration_http_method" -> Json.fromString("POST"), - "type" -> Json.fromString("AWS_PROXY"), - "uri" -> Json.fromString(s"$${aws_lambda_function.lambda.invoke_arn}") + "api_id" -> Json.fromString(s"$${aws_apigatewayv2_api.$TapirApiGateway.id}"), + "integration_type" -> Json.fromString("AWS_PROXY"), + "integration_method" -> Json.fromString("POST"), + "integration_uri" -> Json.fromString(s"$${aws_lambda_function.lambda.invoke_arn}"), + "payload_format_version" -> Json.fromString("2.0") ) ) ) } -case class AwsApiGatewayDeployment(dependsOn: Seq[String]) extends TerraformResource { +case class AwsApiGatewayV2Deployment(dependsOn: Seq[String]) extends TerraformResource { override def json(): Json = terraformResource( - "aws_api_gateway_deployment", + "aws_apigatewayv2_deployment", TapirApiGateway, Json.fromFields( Seq( "depends_on" -> Json.fromValues( - dependsOn.map { d => Json.fromString(s"aws_api_gateway_integration.$d") } + dependsOn.map { d => Json.fromString(s"aws_apigatewayv2_route.$d") } ), - "rest_api_id" -> Json.fromString(s"$${aws_api_gateway_rest_api.$TapirApiGateway.id}"), - "stage_name" -> Json.fromString("test") + "api_id" -> Json.fromString(s"$${aws_apigatewayv2_api.$TapirApiGateway.id}") + ) + ) + ) +} + +case class AwsApiGatewayV2Stage(stage: String, autoDeploy: Boolean) extends TerraformResource { + override def json(): Json = + terraformResource( + "aws_apigatewayv2_stage", + TapirApiGateway, + Json.fromFields( + Seq( + "api_id" -> Json.fromString(s"$${aws_apigatewayv2_api.$TapirApiGateway.id}"), + "name" -> Json.fromString(stage), + "auto_deploy" -> Json.fromBoolean(autoDeploy) ) ) ) diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala index 8b81a4b7e1..c98aed2afa 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -4,17 +4,16 @@ import io.circe.syntax._ import sttp.model.Method import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ -case class AwsTerraformApiGateway(resourceTree: ResourceTree, methods: Seq[AwsTerraformApiGatewayMethod]) { +case class AwsTerraformApiGateway(routes: Seq[AwsApiGatewayRoute]) { def toJson()(implicit options: AwsTerraformOptions): String = { val gateway = this Printer.spaces2.print(gateway.asJson) } } -case class AwsTerraformApiGatewayMethod( +case class AwsApiGatewayRoute( name: String, path: String, httpMethod: Method, - paths: Seq[PathComponent], - requestParameters: Seq[(String, Boolean)] + requestParameters: Seq[(String, String)] ) diff --git a/serverless/aws/terraform/src/test/resources/endpoint_with_params.json b/serverless/aws/terraform/src/test/resources/endpoint_with_params.json index 29826607de..80edc5d20d 100644 --- a/serverless/aws/terraform/src/test/resources/endpoint_with_params.json +++ b/serverless/aws/terraform/src/test/resources/endpoint_with_params.json @@ -43,88 +43,63 @@ "action" : "lambda:InvokeFunction", "function_name" : "${aws_lambda_function.lambda.function_name}", "principal" : "apigateway.amazonaws.com", - "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + "source_arn" : "${aws_apigatewayv2_api.TapirApiGateway.execution_arn}/*/*" } } }, { - "aws_api_gateway_rest_api" : { + "aws_apigatewayv2_api" : { "TapirApiGateway" : { "name" : "TapirApiGateway", - "description" : "Serverless Application" + "description" : "Serverless Application", + "protocol_type" : "HTTP" } } }, { - "aws_api_gateway_deployment" : { + "aws_apigatewayv2_deployment" : { "TapirApiGateway" : { "depends_on" : [ - "aws_api_gateway_integration.GetAccountsIdHistory" + "aws_apigatewayv2_route.GetAccountsIdHistory" ], - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "stage_name" : "test" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}" } } }, { - "aws_api_gateway_resource" : { - "accounts" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", - "path_part" : "accounts" - } - } - }, - { - "aws_api_gateway_resource" : { - "accounts-id" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.accounts.id}", - "path_part" : "{id}" - } - } - }, - { - "aws_api_gateway_resource" : { - "accounts-id-history" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.accounts-id.id}", - "path_part" : "history" + "aws_apigatewayv2_stage" : { + "TapirApiGateway" : { + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "name" : "$default", + "auto_deploy" : false } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_integration" : { "GetAccountsIdHistory" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts-id-history.id}", - "http_method" : "GET", - "authorization" : "NONE", - "request_parameters" : { - "method.request.querystring.limit" : true, - "method.request.header.X-Account" : true, - "method.request.header.X-Secret" : true, - "method.request.path.id" : true - } + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "GetAccountsIdHistory" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.GetAccountsIdHistory.resource_id}", - "http_method" : "${aws_api_gateway_method.GetAccountsIdHistory.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "GET /accounts/{id}/history", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.GetAccountsIdHistory.id}" } } } ], "output" : { "base_url" : { - "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + "value" : "${aws_apigatewayv2_api.TapirApiGateway.api_endpoint}" } } } \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json b/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json index 439b55688e..19ee4ae1ce 100644 --- a/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json +++ b/serverless/aws/terraform/src/test/resources/endpoints_common_paths.json @@ -43,160 +43,129 @@ "action" : "lambda:InvokeFunction", "function_name" : "${aws_lambda_function.lambda.function_name}", "principal" : "apigateway.amazonaws.com", - "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + "source_arn" : "${aws_apigatewayv2_api.TapirApiGateway.execution_arn}/*/*" } } }, { - "aws_api_gateway_rest_api" : { + "aws_apigatewayv2_api" : { "TapirApiGateway" : { "name" : "TapirApiGateway", - "description" : "Serverless Application" + "description" : "Serverless Application", + "protocol_type" : "HTTP" } } }, { - "aws_api_gateway_deployment" : { + "aws_apigatewayv2_deployment" : { "TapirApiGateway" : { "depends_on" : [ - "aws_api_gateway_integration.GetAccountsId", - "aws_api_gateway_integration.PostAccounts", - "aws_api_gateway_integration.GetAccountsIdTransactions", - "aws_api_gateway_integration.PostAccountsIdTransactions" + "aws_apigatewayv2_route.GetAccountsId", + "aws_apigatewayv2_route.PostAccounts", + "aws_apigatewayv2_route.GetAccountsIdTransactions", + "aws_apigatewayv2_route.PostAccountsIdTransactions" ], - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "stage_name" : "test" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}" } } }, { - "aws_api_gateway_resource" : { - "accounts" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", - "path_part" : "accounts" - } - } - }, - { - "aws_api_gateway_resource" : { - "accounts-id" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.accounts.id}", - "path_part" : "{id}" - } - } - }, - { - "aws_api_gateway_resource" : { - "accounts-id-transactions" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.accounts-id.id}", - "path_part" : "transactions" + "aws_apigatewayv2_stage" : { + "TapirApiGateway" : { + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "name" : "$default", + "auto_deploy" : false } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_integration" : { "GetAccountsId" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts-id.id}", - "http_method" : "GET", - "authorization" : "NONE", - "request_parameters" : { - "method.request.path.id" : true - } + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "GetAccountsId" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.GetAccountsId.resource_id}", - "http_method" : "${aws_api_gateway_method.GetAccountsId.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "GET /accounts/{id}", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.GetAccountsId.id}" } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_integration" : { "PostAccounts" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts.id}", - "http_method" : "POST", - "authorization" : "NONE" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "PostAccounts" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.PostAccounts.resource_id}", - "http_method" : "${aws_api_gateway_method.PostAccounts.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "POST /accounts", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.PostAccounts.id}" } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_integration" : { "GetAccountsIdTransactions" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", - "http_method" : "GET", - "authorization" : "NONE", - "request_parameters" : { - "method.request.path.id" : true - } + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "GetAccountsIdTransactions" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.GetAccountsIdTransactions.resource_id}", - "http_method" : "${aws_api_gateway_method.GetAccountsIdTransactions.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "GET /accounts/{id}/transactions", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.GetAccountsIdTransactions.id}" } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_integration" : { "PostAccountsIdTransactions" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.accounts-id-transactions.id}", - "http_method" : "POST", - "authorization" : "NONE", - "request_parameters" : { - "method.request.path.id" : true - } + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "PostAccountsIdTransactions" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.PostAccountsIdTransactions.resource_id}", - "http_method" : "${aws_api_gateway_method.PostAccountsIdTransactions.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "POST /accounts/{id}/transactions", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.PostAccountsIdTransactions.id}" } } } ], "output" : { "base_url" : { - "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + "value" : "${aws_apigatewayv2_api.TapirApiGateway.api_endpoint}" } } } \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/resources/root_endpoint.json b/serverless/aws/terraform/src/test/resources/root_endpoint.json index 0075d3b402..f65843e805 100644 --- a/serverless/aws/terraform/src/test/resources/root_endpoint.json +++ b/serverless/aws/terraform/src/test/resources/root_endpoint.json @@ -43,55 +43,63 @@ "action" : "lambda:InvokeFunction", "function_name" : "${aws_lambda_function.lambda.function_name}", "principal" : "apigateway.amazonaws.com", - "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + "source_arn" : "${aws_apigatewayv2_api.TapirApiGateway.execution_arn}/*/*" } } }, { - "aws_api_gateway_rest_api" : { + "aws_apigatewayv2_api" : { "TapirApiGateway" : { "name" : "TapirApiGateway", - "description" : "Serverless Application" + "description" : "Serverless Application", + "protocol_type" : "HTTP" } } }, { - "aws_api_gateway_deployment" : { + "aws_apigatewayv2_deployment" : { "TapirApiGateway" : { "depends_on" : [ - "aws_api_gateway_integration.AnyRoot" + "aws_apigatewayv2_route.AnyRoot" ], - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "stage_name" : "test" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}" } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_stage" : { + "TapirApiGateway" : { + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "name" : "$default", + "auto_deploy" : false + } + } + }, + { + "aws_apigatewayv2_integration" : { "AnyRoot" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", - "http_method" : "ANY", - "authorization" : "NONE" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "AnyRoot" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.AnyRoot.resource_id}", - "http_method" : "${aws_api_gateway_method.AnyRoot.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "ANY /", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.AnyRoot.id}" } } } ], "output" : { "base_url" : { - "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + "value" : "${aws_apigatewayv2_api.TapirApiGateway.api_endpoint}" } } } \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/resources/simple_endpoint.json b/serverless/aws/terraform/src/test/resources/simple_endpoint.json index 31043d2380..8f8515fd1a 100644 --- a/serverless/aws/terraform/src/test/resources/simple_endpoint.json +++ b/serverless/aws/terraform/src/test/resources/simple_endpoint.json @@ -43,73 +43,63 @@ "action" : "lambda:InvokeFunction", "function_name" : "${aws_lambda_function.lambda.function_name}", "principal" : "apigateway.amazonaws.com", - "source_arn" : "${aws_api_gateway_rest_api.TapirApiGateway.execution_arn}/*/*" + "source_arn" : "${aws_apigatewayv2_api.TapirApiGateway.execution_arn}/*/*" } } }, { - "aws_api_gateway_rest_api" : { + "aws_apigatewayv2_api" : { "TapirApiGateway" : { "name" : "TapirApiGateway", - "description" : "Serverless Application" + "description" : "Serverless Application", + "protocol_type" : "HTTP" } } }, { - "aws_api_gateway_deployment" : { + "aws_apigatewayv2_deployment" : { "TapirApiGateway" : { "depends_on" : [ - "aws_api_gateway_integration.GetHelloWorld" + "aws_apigatewayv2_route.GetHelloWorld" ], - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "stage_name" : "test" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}" } } }, { - "aws_api_gateway_resource" : { - "hello" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_rest_api.TapirApiGateway.root_resource_id}", - "path_part" : "hello" - } - } - }, - { - "aws_api_gateway_resource" : { - "hello-world" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "parent_id" : "${aws_api_gateway_resource.hello.id}", - "path_part" : "world" + "aws_apigatewayv2_stage" : { + "TapirApiGateway" : { + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "name" : "$default", + "auto_deploy" : false } } }, { - "aws_api_gateway_method" : { + "aws_apigatewayv2_integration" : { "GetHelloWorld" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_resource.hello-world.id}", - "http_method" : "GET", - "authorization" : "NONE" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "integration_type" : "AWS_PROXY", + "integration_method" : "POST", + "integration_uri" : "${aws_lambda_function.lambda.invoke_arn}", + "payload_format_version" : "2.0" } } }, { - "aws_api_gateway_integration" : { + "aws_apigatewayv2_route" : { "GetHelloWorld" : { - "rest_api_id" : "${aws_api_gateway_rest_api.TapirApiGateway.id}", - "resource_id" : "${aws_api_gateway_method.GetHelloWorld.resource_id}", - "http_method" : "${aws_api_gateway_method.GetHelloWorld.http_method}", - "integration_http_method" : "POST", - "type" : "AWS_PROXY", - "uri" : "${aws_lambda_function.lambda.invoke_arn}" + "api_id" : "${aws_apigatewayv2_api.TapirApiGateway.id}", + "route_key" : "GET /hello/world", + "authorization_type" : "NONE", + "target" : "integrations/${aws_apigatewayv2_integration.GetHelloWorld.id}" } } } ], "output" : { "base_url" : { - "value" : "${aws_api_gateway_deployment.TapirApiGateway.invoke_url}" + "value" : "${aws_apigatewayv2_api.TapirApiGateway.api_endpoint}" } } } \ No newline at end of file diff --git a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala index 99a99747be..73c0983f42 100644 --- a/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala +++ b/serverless/aws/terraform/src/test/scala/sttp/tapir/serverless/aws/terraform/VerifyTerraformTemplateTest.scala @@ -25,7 +25,6 @@ class VerifyTerraformTemplateTest extends AnyFunSuite with Matchers { val expectedJson = load("root_endpoint.json") val actualJson = AwsTerraformInterpreter.toTerraformConfig(List(ep)).toJson() - println(actualJson) expectedJson shouldBe noIndentation(actualJson) } From 794c18dfe1dfd20f37ce1d8c42e2cc7ee4f90f88 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Wed, 26 May 2021 17:15:50 +0200 Subject: [PATCH 29/35] cr updates, docs --- build.sbt | 61 +++++++---------- doc/server/aws.md | 66 +++++++++++++++---- .../aws/examples/LambdaApiExample.scala | 6 -- .../aws/examples/SamTemplateExample.scala | 4 ++ .../aws/examples/TerraformConfigExample.scala | 17 ++--- .../aws/lambda/tests/LambdaHandler.scala | 2 +- .../aws/lambda/AwsServerInterpreter.scala | 3 +- .../tapir/serverless/aws/lambda/package.scala | 4 +- .../aws/lambda/runtime/AwsLambdaRuntime.scala | 4 +- .../EndpointsToTerraformConfig.scala | 32 ++------- .../serverless/aws/terraform/model.scala | 3 +- 11 files changed, 102 insertions(+), 100 deletions(-) diff --git a/build.sbt b/build.sbt index 665a3b1ac9..de86b73f31 100644 --- a/build.sbt +++ b/build.sbt @@ -898,8 +898,6 @@ lazy val awsLambda: ProjectMatrix = (projectMatrix in file("serverless/aws/lambd .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, cats, circeJson, awsSam, sttpStubServer % "test", tests % "test", serverTests) -lazy val sam = Process("sam local start-api --warm-containers EAGER").run() - // integration tests for lambda interpreter // it's a separate project since it needs a fat jar with lambda code which cannot be build from tests sources // runs sam local cmd line tool to start AWS Api Gateway with lambda proxy @@ -915,25 +913,30 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ case _ @("scala/annotation/nowarn.class" | "scala/annotation/nowarn$.class") => MergeStrategy.first case x => (assembly / assemblyMergeStrategy).value(x) }, - Test / test := { - if (scalaVersion.value == scala2_13) - (Test / test) - .dependsOn((Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate")) - .dependsOn(assembly) - .value - else {} // skip tests (run them only once for scala 2.13) - }, - Test / testOptions += Tests.Setup(() => { + Test / test := (Test / test) + .dependsOn((Compile / runMain).toTask(" sttp.tapir.serverless.aws.lambda.tests.LambdaSamTemplate")) + .dependsOn(assembly) + .value, + Test / testOptions ++= { val log = sLog.value - val samReady = PollingUtils.poll(30.seconds, 1.second) { - sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) - } - if (!samReady) { - sam.destroy() - log.err("Failed to start sam local-api") - } - }), - Test / testOptions += Tests.Cleanup(() => sam.destroy()), + lazy val sam = Process("sam local start-api --warm-containers EAGER").run() + Seq( + Tests.Setup(() => { + val samReady = PollingUtils.poll(30.seconds, 1.second) { + sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) + } + if (!samReady) { + sam.destroy() + sam.exitValue() + log.error("failed to start sam local within 30 seconds") + } + }), + Tests.Cleanup(() => { + sam.destroy() + sam.exitValue() + }) + ) + }, Test / parallelExecution := false ) .jvmPlatform(scalaVersions = allScalaVersions) @@ -965,8 +968,6 @@ lazy val awsTerraform: ProjectMatrix = (projectMatrix in file("serverless/aws/te .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, tests % Test) -lazy val runSamExample = taskKey[Unit]("runs aws lambda example on sam local") - lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/examples")) .settings(commonJvmSettings) .settings( @@ -979,22 +980,6 @@ lazy val awsExamples: ProjectMatrix = (projectMatrix in file("serverless/aws/exa case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first case _ @("scala/annotation/nowarn.class" | "scala/annotation/nowarn$.class") => MergeStrategy.first case x => (assembly / assemblyMergeStrategy).value(x) - }, - runSamExample := { - val log = sLog.value - (Compile / runMain).toTask(" sttp.tapir.serverless.aws.examples.SamTemplateExample$").value - val samReady = PollingUtils.poll(20.seconds, 1.second) { - sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/api/hello")) - } - if (!samReady) { - sam.destroy() - log.err("Failed to start sam local-api, have your run sbt assembly?") - } else { - log.info("Lambda function is available under http://127.0.0.1:3000/api/hello ...") - log.info("Press any key to exit ...") - scala.io.StdIn.readLine() - sam.destroy() - } } ) .jvmPlatform(scalaVersions = allScalaVersions) diff --git a/doc/server/aws.md b/doc/server/aws.md index bd1db3231b..53ab09a4ad 100644 --- a/doc/server/aws.md +++ b/doc/server/aws.md @@ -1,24 +1,66 @@ # Running behind AWS API Gateway -[AWS API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) provides a proxy integration -with [AWS Lambda](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) which allows -you to implement API routes using Lambda functions. [AWS SAM](https://aws.amazon.com/serverless/sam/) on the other hand provides a configuration mechanism -for binding HTTP APIs to Lambda functions. +[AWS API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) provides a proxy +integration +with [AWS Lambda](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) +which allows you to implement API routes using Lambda functions. On the other hand tools +like [AWS SAM](https://aws.amazon.com/serverless/sam/) and [Terraform](https://www.terraform.io/) provides a +configuration mechanism for binding AWS Api Gateway routes to Lambda functions and automating cloud deployments. -This concept of serverless API has been adapted to Tapir in form of two components. +This concept of serverless API has been adapted to Tapir in form of three components. + +The first one is `AwsServerInterpreter` which routes AWS API Gateway requests to responses just as any other server +interpreter does. It should be used in your lambda function code. -The first one is `AwsServerInterpreter` which routes AWS API Gateway requests to responses just as any other server interpreter does. -It should be used in your lambda function code. ```scala "com.softwaremill.sttp.tapir" %% "tapir-aws-lambda" % "@VERSION@" ``` -The second one is `AwsSamInterpreter` which interprets Tapir `Endpoints` into AWS SAM configuration file. -It should be used to configure your API Gateway. +The remaining two are `AwsSamInterpreter` which interprets Tapir `Endpoints` into AWS SAM template file +and `AwsTerraformInterpreter` which interprets `Endpoints` into terraform configuration file. One of them should be used +to configure your API Gateway. + ```scala "com.softwaremill.sttp.tapir" %% "tapir-aws-sam" % "@VERSION@" +"com.softwaremill.sttp.tapir" %% "tapir-aws-terraform" % "@VERSION@" ``` -In our [GitHub repository](https://github.com/softwaremill/tapir/tree/master/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample) -you'll find a runnable example which uses [AWS SAM command line tool](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-command-reference.html) -to run a "hello world" serverless application locally. +## Examples + +In +our [GitHub repository](https://github.com/softwaremill/tapir/tree/master/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples) +you'll find a `LambdaApiExample` handler which uses `AwsServerInterpreter` to route a hello endpoint along +with `SamTemplateExample` and `TerraformConfigExample` which interpret endpoints to SAM/Terraform configuration. Go +ahead and clone tapir project and select `project awsExamples` from sbt shell. + +Make sure you +have [AWS command line tools installed](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html). + +### SAM + +To try it out using SAM template you don't need an AWS account. + +* install [AWS SAM command line tool](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-command-reference.html) +* run `assembly` task and `runMain sttp.tapir.serverless.aws.examples.SamTemplateExample` +* open a terminal and in tapir root directory run `sam local start-api --warm-containers EAGER` + +That will create `template.yaml` and start up AWS Api Gateway locally. Hello endpoint will be available +under `curl http://127.0.0.1:3000/api/hello`. First invocation will take a while but subsequent ones will be faster +since the created container will be reused. + +### Terraform + +To run the example using terraform you will need an AWS account, and an S3 bucket. + +* install [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli) +* run `assembly` task +* open a terminal in `tapir/serverless/aws/examples/target/jvm-2.13` directory. That's where the fat jar is saved. You + need to upload it into your s3 bucket. Using command line + tools: `aws s3 cp tapir-aws-examples.jar s3://{your-bucket}/{your-key}`. +* Run `runMain sttp.tapir.serverless.aws.examples.TerraformConfigExample {your-aws-region} {your-bucket} {your-key}` +* open terminal in tapir root directory, run `terraform init` and `terraform apply` + +That will create `api_gateway.tf.json` configuration and deploy Api Gateway and lambda function to AWS. Terraform will +output the url of the created API Gateway which you can call followed by `/api/hello` path. + +To destroy all the created resources run `terraform destroy`. \ No newline at end of file diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala index 681b77c81d..aba5a70620 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala @@ -15,12 +15,6 @@ import sttp.tapir.serverless.aws.lambda._ import java.io.{BufferedWriter, InputStream, OutputStream, OutputStreamWriter} import java.nio.charset.StandardCharsets.UTF_8 -/** Example assumes that you have `sam local` installed on your OS. Installation is described here: - * https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html - * - * Select `awsExamples` project from sbt shell and run `assembly` task to build a fat jar with lambda handler. - * Then `runSamExample` to generate `template.yaml` and start up `sam local`. - */ object LambdaApiExample extends RequestStreamHandler { val helloEndpoint: ServerEndpoint[Unit, Unit, String, Any, IO] = endpoint.get diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala index 9e800131ee..6f5b04dbb0 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala @@ -9,6 +9,10 @@ import java.nio.file.{Files, Paths} /** Before running the actual example we need to interpret our api as SAM template */ object SamTemplateExample extends App { + val dir: String = sys.env("$PWD") + + println(dir) + val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString implicit val samOptions: AwsSamOptions = AwsSamOptions( diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala index 466f38538b..ab00b2082f 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/TerraformConfigExample.scala @@ -13,17 +13,18 @@ import scala.concurrent.duration.DurationInt /** Before running the actual example we need to interpret our api as Terraform resources */ object TerraformConfigExample extends App { + if (args.length != 3) sys.error("Usage: [aws region] [s3 bucket] [s3 key]") + + val region = args(0) + val bucket = args(1) + val key = args(2) + implicit val terraformOptions: AwsTerraformOptions = AwsTerraformOptions( - "eu-central-1", + region, functionName = "PersonsFunction", apiGatewayName = "PersonsApiGateway", autoDeploy = true, - functionSource = S3Source( - "terraform-example-kubinio", - "v1.0.0/tapir-aws-examples.jar", - "java11", - "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest" - ), + functionSource = S3Source(bucket, key, "java11", "sttp.tapir.serverless.aws.examples.LambdaApiExample::handleRequest"), timeout = 30.seconds, memorySize = 1024 ) @@ -32,5 +33,5 @@ object TerraformConfigExample extends App { val apiGatewayConfig = Printer.spaces2.print(apiGateway.asJson) - Files.write(Paths.get("serverless/aws/terraform/example/api_gateway.tf.json"), apiGatewayConfig.getBytes(UTF_8)) + Files.write(Paths.get("api_gateway.tf.json"), apiGatewayConfig.getBytes(UTF_8)) } diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 91772b5f3c..82e63835ea 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -24,7 +24,7 @@ object LambdaHandler extends RequestStreamHandler { (decode[AwsRequest](json) match { case Right(awsRequest) => route(awsRequest) - case Left(_) => IO.pure(AwsResponse(isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, Map.empty, "")) + case Left(_) => IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, "")) }).map { awsRes => val writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)) writer.write(Printer.noSpaces.print(awsRes.asJson)) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala index 9518b64bfa..2d450dbe0f 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala @@ -1,6 +1,5 @@ package sttp.tapir.serverless.aws.lambda -import cats.data.Kleisli import cats.effect.Sync import sttp.model.StatusCode import sttp.monad.syntax._ @@ -28,7 +27,7 @@ trait AwsServerInterpreter { implicit val monad: CatsMonadError[F] = new CatsMonadError[F] implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] - Kleisli { request: AwsRequest => + { request: AwsRequest => implicit val monad: CatsMonadError[F] = new CatsMonadError[F] implicit val bodyListener: BodyListener[F, String] = new AwsBodyListener[F] val serverRequest = new AwsServerRequest(request) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala index be6d6e7a87..77c40a163a 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/package.scala @@ -1,7 +1,5 @@ package sttp.tapir.serverless.aws -import cats.data.Kleisli - package object lambda { - type Route[F[_]] = Kleisli[F, AwsRequest, AwsResponse] + type Route[F[_]] = AwsRequest => F[AwsResponse] } diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala index 64fa14668b..ac6f831682 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala @@ -20,8 +20,10 @@ import scala.concurrent.ExecutionContext import scala.concurrent.duration.DurationInt // loosely based on https://github.com/carpe/scalambda/blob/master/native/src/main/scala/io/carpe/scalambda/native/ScalambdaIO.scala -abstract class AwsLambdaRuntime[F[_]: ConcurrentEffect: ContextShift] extends StrictLogging { +abstract class AwsLambdaRuntime[F[_]] extends StrictLogging { implicit def executionContext: ExecutionContext + implicit def contextShift: ContextShift[F] + implicit def concurrentEffect: ConcurrentEffect[F] implicit def serverOptions: AwsServerOptions[F] = AwsServerOptions.customInterceptors() def endpoints: Iterable[ServerEndpoint[_, _, _, Any, F]] diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala index 8487563434..b8ef23710c 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/EndpointsToTerraformConfig.scala @@ -2,19 +2,12 @@ package sttp.tapir.serverless.aws.terraform import sttp.model.Method import sttp.tapir.internal._ -import sttp.tapir.serverless.aws.terraform.PathComponent.DefaultParamName -import sttp.tapir.{Endpoint, EndpointInput, _} +import sttp.tapir.{Endpoint, EndpointInput} private[terraform] object EndpointsToTerraformConfig { - type IdMethodEndpointInput = (String, Method, EndpointInput.Basic[_]) - - private val headerPrefix = "method.request.header." - private val queryPrefix = "method.request.querystring." - private val pathPrefix = "method.request.path." - def apply(eps: List[Endpoint[_, _, _, _]])(implicit options: AwsTerraformOptions): AwsTerraformApiGateway = { - val methods: Seq[AwsApiGatewayRoute] = eps.map { endpoint => + val routes: Seq[AwsApiGatewayRoute] = eps.map { endpoint => val method = endpoint.httpMethod.getOrElse(Method("ANY")) val basicInputs = endpoint.input.asVectorOfBasicInputs() @@ -24,7 +17,7 @@ private[terraform] object EndpointsToTerraformConfig { input match { case fp @ EndpointInput.FixedPath(p, _, _) => (acc :+ Left(fp) -> p, c) case pc @ EndpointInput.PathCapture(name, _, _) => - (acc :+ Right(pc) -> name.getOrElse(s"$DefaultParamName$c"), if (name.isEmpty) c + 1 else c) + (acc :+ Right(pc) -> name.getOrElse(s"param$c"), if (name.isEmpty) c + 1 else c) case _ => (acc, c) } } @@ -40,24 +33,9 @@ private[terraform] object EndpointsToTerraformConfig { val nameComponents = if (pathComponents.isEmpty) Vector("root") else pathComponents.map { case (_, name) => name } val name = s"${method.method.toLowerCase.capitalize}${nameComponents.map(_.toLowerCase.capitalize).mkString}" - val requestParameters: Seq[(String, Boolean)] = basicInputs.collect { - case EndpointIO.Header(name, codec, _) => s"$headerPrefix$name" -> !codec.schema.isOptional - case EndpointIO.FixedHeader(header, codec, _) => s"$headerPrefix${header.name}" -> !codec.schema.isOptional - case EndpointInput.Query(name, codec, _) => s"$queryPrefix$name" -> !codec.schema.isOptional - } ++ pathComponents.collect { case c @ (Right(pc), name) => s"$pathPrefix${name}" -> !pc.codec.schema.isOptional } - - AwsApiGatewayRoute( - name, - path, - method, - Seq.empty - ) + AwsApiGatewayRoute(name, path, method) } - AwsTerraformApiGateway(methods) + AwsTerraformApiGateway(routes) } } - -object PathComponent { - val DefaultParamName = "param" -} diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala index c98aed2afa..e5576cde20 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -14,6 +14,5 @@ case class AwsTerraformApiGateway(routes: Seq[AwsApiGatewayRoute]) { case class AwsApiGatewayRoute( name: String, path: String, - httpMethod: Method, - requestParameters: Seq[(String, String)] + httpMethod: Method ) From cf9b43d553c6c8dbff06b11daba690a6c9fac2d5 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 27 May 2021 08:35:04 +0200 Subject: [PATCH 30/35] cleanup --- .../tapir/serverless/aws/examples/SamTemplateExample.scala | 4 ---- 1 file changed, 4 deletions(-) diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala index 6f5b04dbb0..9e800131ee 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/SamTemplateExample.scala @@ -9,10 +9,6 @@ import java.nio.file.{Files, Paths} /** Before running the actual example we need to interpret our api as SAM template */ object SamTemplateExample extends App { - val dir: String = sys.env("$PWD") - - println(dir) - val jarPath = Paths.get("serverless/aws/examples/target/jvm-2.13/tapir-aws-examples.jar").toAbsolutePath.toString implicit val samOptions: AwsSamOptions = AwsSamOptions( From 29c840c04f5e9e1a781cc8ccdc16611ef76cd3dd Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 27 May 2021 19:07:27 +0200 Subject: [PATCH 31/35] runtime tests --- build.sbt | 8 +- .../aws/examples/LambdaApiExample.scala | 2 +- .../aws/lambda/tests/LambdaHandler.scala | 4 +- .../tests/AwsLambdaSamLocalHttpTest.scala | 3 + ...a => AwsCatsEffectServerInterpreter.scala} | 4 +- .../aws/lambda/AwsToResponseBody.scala | 6 +- .../aws/lambda/runtime/AwsLambdaRuntime.scala | 106 ++---------- .../lambda/runtime/AwsLambdaRuntimeLoop.scala | 103 ++++++++++++ .../AwsLambdaCreateServerStubTest.scala | 4 +- .../aws/lambda/AwsLambdaStubHttpTest.scala | 4 +- .../runtime/AwsLambdaRuntimeLoopTest.scala | 154 ++++++++++++++++++ .../sttp/tapir/serverless/aws/sam/model.scala | 5 +- .../serverless/aws/terraform/model.scala | 5 +- 13 files changed, 290 insertions(+), 118 deletions(-) rename serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/{AwsServerInterpreter.scala => AwsCatsEffectServerInterpreter.scala} (95%) create mode 100644 serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoop.scala create mode 100644 serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala diff --git a/build.sbt b/build.sbt index de86b73f31..74f30fc71d 100644 --- a/build.sbt +++ b/build.sbt @@ -919,6 +919,7 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ .value, Test / testOptions ++= { val log = sLog.value + // process uses template.yaml which is generated by `LambdaSamTemplate` called above lazy val sam = Process("sam local start-api --warm-containers EAGER").run() Seq( Tests.Setup(() => { @@ -927,13 +928,14 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ } if (!samReady) { sam.destroy() - sam.exitValue() - log.error("failed to start sam local within 30 seconds") + val exit = sam.exitValue() + log.error(s"failed to start sam local within 30 seconds (exit code: $exit") } }), Tests.Cleanup(() => { sam.destroy() - sam.exitValue() + val exit = sam.exitValue() + log.info(s"stopped sam local (exit code: $exit") }) ) }, diff --git a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala index aba5a70620..a165cb25d4 100644 --- a/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala +++ b/serverless/aws/examples/src/main/scala/sttp/tapir/serverless/aws/examples/LambdaApiExample.scala @@ -24,7 +24,7 @@ object LambdaApiExample extends RequestStreamHandler { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) - val route: Route[IO] = AwsServerInterpreter.toRoute(helloEndpoint) + val route: Route[IO] = AwsCatsEffectServerInterpreter.toRoute(helloEndpoint) override def handleRequest(input: InputStream, output: OutputStream, context: Context): Unit = { diff --git a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala index 82e63835ea..a714e16103 100644 --- a/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala +++ b/serverless/aws/lambda-tests/src/main/scala/sttp/tapir/serverless/aws/lambda/tests/LambdaHandler.scala @@ -17,9 +17,7 @@ object LambdaHandler extends RequestStreamHandler { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) - allEndpoints.foreach(e => println(e.endpoint.showDetail)) - - val route: Route[IO] = AwsServerInterpreter.toRoute(allEndpoints.toList) + val route: Route[IO] = AwsCatsEffectServerInterpreter.toRoute(allEndpoints.toList) val json = new String(input.readAllBytes(), StandardCharsets.UTF_8) (decode[AwsRequest](json) match { diff --git a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala index 9552bc8cc2..adde9a6cf5 100644 --- a/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala +++ b/serverless/aws/lambda-tests/src/test/scala/sttp/tapir/serverless/aws/lambda/tests/AwsLambdaSamLocalHttpTest.scala @@ -12,6 +12,9 @@ import sttp.model.{Header, Uri} import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.tests.backendResource +/** Requires running sam-local process with template generated by `LambdaSamTemplate`, + * it's automated in sbt test task but requires sam cli installed. + */ class AwsLambdaSamLocalHttpTest extends AnyFunSuite { private val baseUri: Uri = uri"http://localhost:3000" diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsCatsEffectServerInterpreter.scala similarity index 95% rename from serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala rename to serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsCatsEffectServerInterpreter.scala index 2d450dbe0f..aa2e61429b 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsServerInterpreter.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsCatsEffectServerInterpreter.scala @@ -10,7 +10,7 @@ import sttp.tapir.server.interpreter.{BodyListener, ServerInterpreter} import scala.reflect.ClassTag -trait AwsServerInterpreter { +trait AwsCatsEffectServerInterpreter { def toRoute[I, E, O, F[_]](e: Endpoint[I, E, O, Any])( logic: I => F[Either[E, O]] )(implicit serverOptions: AwsServerOptions[F], sync: Sync[F]): Route[F] = toRoute(e.serverLogic(logic)) @@ -49,4 +49,4 @@ trait AwsServerInterpreter { } } -object AwsServerInterpreter extends AwsServerInterpreter +object AwsCatsEffectServerInterpreter extends AwsCatsEffectServerInterpreter diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala index 94164b9567..52eda6b654 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/AwsToResponseBody.scala @@ -37,7 +37,11 @@ private[lambda] class AwsToResponseBody[F[_]](implicit options: AwsServerOptions private def safeRead(byteBuffer: ByteBuffer): Array[Byte] = { if (byteBuffer.hasArray) { - byteBuffer.array() + if (byteBuffer.array().length != byteBuffer.limit()) { + val array = new Array[Byte](byteBuffer.limit()) + byteBuffer.get(array, 0, byteBuffer.limit()) + array + } else byteBuffer.array() } else { val array = new Array[Byte](byteBuffer.remaining()) byteBuffer.get(array) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala index ac6f831682..0b3375dd39 100644 --- a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntime.scala @@ -1,113 +1,27 @@ package sttp.tapir.serverless.aws.lambda.runtime -import cats.effect.{Blocker, ConcurrentEffect, ContextShift, Resource} -import cats.syntax.either._ +import cats.effect.{Blocker, ConcurrentEffect, ContextShift} +import cats.syntax.all._ import com.typesafe.scalalogging.StrictLogging -import io.circe.Printer -import io.circe.generic.auto._ -import io.circe.parser.decode -import io.circe.syntax._ import org.http4s.client.blaze.BlazeClientBuilder -import sttp.client3._ import sttp.client3.http4s.Http4sBackend -import sttp.monad.MonadError -import sttp.monad.syntax._ -import sttp.tapir.integ.cats.CatsMonadError import sttp.tapir.server.ServerEndpoint import sttp.tapir.serverless.aws.lambda._ import scala.concurrent.ExecutionContext import scala.concurrent.duration.DurationInt -// loosely based on https://github.com/carpe/scalambda/blob/master/native/src/main/scala/io/carpe/scalambda/native/ScalambdaIO.scala -abstract class AwsLambdaRuntime[F[_]] extends StrictLogging { +abstract class AwsLambdaRuntime[F[_]: ContextShift: ConcurrentEffect] extends StrictLogging { + def endpoints: Iterable[ServerEndpoint[_, _, _, Any, F]] implicit def executionContext: ExecutionContext - implicit def contextShift: ContextShift[F] - implicit def concurrentEffect: ConcurrentEffect[F] - implicit def serverOptions: AwsServerOptions[F] = AwsServerOptions.customInterceptors() - def endpoints: Iterable[ServerEndpoint[_, _, _, Any, F]] - - protected val backend: Resource[F, SttpBackend[F, Any]] = Http4sBackend.usingBlazeClientBuilder( - BlazeClientBuilder[F](executionContext).withConnectTimeout(0.seconds), - Blocker.liftExecutionContext(implicitly) - ) def main(args: Array[String]): Unit = { - val route: Route[F] = AwsServerInterpreter.toRoute(endpoints.toList) - implicit val monad: MonadError[F] = new CatsMonadError[F] - - val runtimeApiInvocationUri = uri"http://${sys.env("AWS_LAMBDA_RUNTIME_API")}/2018-06-01/runtime/invocation" - val nextEventRequest = basicRequest.get(uri"$runtimeApiInvocationUri/next").response(asStringAlways).readTimeout(0.seconds) - - val pollEvent: F[RequestEvent] = { - logger.info(s"Fetching request event") - backend - .use(nextEventRequest.send(_)) - .flatMap { response => - response.header("lambda-runtime-aws-request-id") match { - case Some(id) => RequestEvent(id, response.body).unit - case _ => - monad.error[RequestEvent](new RuntimeException(s"Missing lambda-runtime-aws-request-id header in request event $response")) - } - } - .handleError { case e => - logger.error("Failed to fetch request event", e) - monad.error(e) - } - } - - val decodeEvent: RequestEvent => F[AwsRequest] = event => { - decode[AwsRequest](event.body) match { - case Right(awsRequest) => awsRequest.unit - case Left(e) => - logger.error(s"Failed to decode request event ${event.requestId}", e.getCause) - monad.error(e.getCause) - } - } - - val routeRequest: (RequestEvent, AwsRequest) => F[Either[Throwable, AwsResponse]] = (event, request) => - route(request).map(_.asRight[Throwable]).handleError { case e => - logger.error(s"Failed to process request event ${event.requestId}", e) - e.asLeft[AwsResponse].unit - } - - val sendResponse: (RequestEvent, AwsResponse) => F[Unit] = (event, response) => - backend - .use { b => - basicRequest - .post(uri"$runtimeApiInvocationUri/${event.requestId}/response") - .body(Printer.noSpaces.print(response.asJson)) - .send(b) - } - .map(_ => ()) - - val sendError: (RequestEvent, Throwable) => F[Unit] = (event, e) => - backend - .use { b => - basicRequest.post(uri"$runtimeApiInvocationUri/${event.requestId}/error").body(e.getMessage).send(b) - } - .map(_ => ()) - - val sendResult: (RequestEvent, Either[Throwable, AwsResponse]) => F[Unit] = (event, result) => - result match { - case Right(response) => - logger.info(s"Request event ${event.requestId} completed successfully") - sendResponse(event, response) - case Left(e) => - logger.error(s"Request event ${event.requestId} failed", e) - sendError(event, e) - } - - val loop = for { - event <- pollEvent - request <- decodeEvent(event) - result <- routeRequest(event, request) - _ <- sendResult(event, result) - } yield () - - while (true) ConcurrentEffect[F].toIO(loop).unsafeRunSync() + val backend = Http4sBackend.usingBlazeClientBuilder( + BlazeClientBuilder[F](executionContext).withConnectTimeout(0.seconds), + Blocker.liftExecutionContext(implicitly) + ) + val route: Route[F] = AwsCatsEffectServerInterpreter.toRoute(endpoints.toList) + ConcurrentEffect[F].toIO(AwsLambdaRuntimeLoop(route, sys.env("AWS_LAMBDA_RUNTIME_API"), backend)).foreverM.unsafeRunSync() } } - -case class RequestEvent(requestId: String, body: String) diff --git a/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoop.scala b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoop.scala new file mode 100644 index 0000000000..6cf0809a95 --- /dev/null +++ b/serverless/aws/lambda/src/main/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoop.scala @@ -0,0 +1,103 @@ +package sttp.tapir.serverless.aws.lambda.runtime + +import cats.effect.{ConcurrentEffect, ContextShift, Resource} +import cats.syntax.either._ +import com.typesafe.scalalogging.StrictLogging +import io.circe.Printer +import io.circe.generic.auto._ +import io.circe.parser.decode +import io.circe.syntax._ +import sttp.client3._ +import sttp.monad.MonadError +import sttp.monad.syntax._ +import sttp.tapir.integ.cats.CatsMonadError +import sttp.tapir.serverless.aws.lambda.{AwsRequest, AwsResponse, Route} + +import scala.concurrent.duration.DurationInt + +// loosely based on https://github.com/carpe/scalambda/blob/master/native/src/main/scala/io/carpe/scalambda/native/ScalambdaIO.scala +object AwsLambdaRuntimeLoop extends StrictLogging { + + def apply[F[_]: ContextShift: ConcurrentEffect]( + route: Route[F], + awsRuntimeApi: String, + backend: Resource[F, SttpBackend[F, Any]] + ): F[Either[Throwable, Unit]] = { + implicit val monad: MonadError[F] = new CatsMonadError[F] + + val runtimeApiInvocationUri = uri"http://${awsRuntimeApi}/2018-06-01/runtime/invocation" + + /** Make request (without a timeout as prescribed by the AWS Custom Lambda Runtime documentation). + * This is due to the possibility of the runtime being frozen between lambda function invocations. + */ + val nextEventRequest = basicRequest.get(uri"$runtimeApiInvocationUri/next").response(asStringAlways).readTimeout(0.seconds) + + val pollEvent: F[RequestEvent] = { + logger.info(s"Fetching request event") + backend + .use(nextEventRequest.send(_)) + .flatMap { response => + response.header("lambda-runtime-aws-request-id") match { + case Some(id) => RequestEvent(id, response.body).unit + case _ => + monad.error[RequestEvent](new RuntimeException(s"Missing lambda-runtime-aws-request-id header in request event $response")) + } + } + .handleError { case e => monad.error(new RuntimeException(s"Failed to fetch request event, ${e.getMessage}")) } + } + + val decodeEvent: RequestEvent => F[AwsRequest] = event => { + decode[AwsRequest](event.body) match { + case Right(awsRequest) => awsRequest.unit + case Left(e) => monad.error(new RuntimeException(s"Failed to decode request event ${event.requestId}, ${e.getMessage}")) + } + } + + val routeRequest: (RequestEvent, AwsRequest) => F[Either[Throwable, AwsResponse]] = (event, request) => + route(request).map(_.asRight[Throwable]).handleError { case e => + logger.error(s"Failed to process request event ${event.requestId}", e) + e.asLeft[AwsResponse].unit + } + + val sendResponse: (RequestEvent, AwsResponse) => F[Unit] = (event, response) => + backend + .use { b => + basicRequest + .post(uri"$runtimeApiInvocationUri/${event.requestId}/response") + .body(Printer.noSpaces.print(response.asJson)) + .send(b) + } + .map(_ => ()) + .handleError { case e => monad.error(new RuntimeException(s"Failed to send response for event ${event.requestId}")) } + + val sendError: (RequestEvent, Throwable) => F[Unit] = (event, e) => + backend + .use { b => + basicRequest.post(uri"$runtimeApiInvocationUri/${event.requestId}/error").body(e.getMessage).send(b) + } + .map(_ => ()) + .handleError { case e => monad.error(new RuntimeException(s"Failed to send error for event ${event.requestId}")) } + + val sendResult: (RequestEvent, Either[Throwable, AwsResponse]) => F[Unit] = (event, result) => + result match { + case Right(response) => + logger.info(s"Request event ${event.requestId} completed successfully") + sendResponse(event, response) + case Left(e) => + logger.error(s"Request event ${event.requestId} failed", e) + sendError(event, e) + } + + (for { + event <- pollEvent + request <- decodeEvent(event) + result <- routeRequest(event, request) + _ <- sendResult(event, result) + } yield ().asRight[Throwable]).handleError { case e => + logger.error(e.getMessage) + e.asLeft[Unit].unit + } + } +} + +case class RequestEvent(requestId: String, body: String) diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala index c46fc5b3e6..a436c95e17 100644 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala @@ -30,7 +30,7 @@ class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) ) val se: ServerEndpoint[I, E, O, Any, IO] = e.serverLogic(fn) - val route: Route[IO] = AwsServerInterpreter.toRoute(se) + val route: Route[IO] = AwsCatsEffectServerInterpreter.toRoute(se) val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) Test(name)(runTest(stubBackend(route), uri"http://localhost:3000").unsafeRunSync()) } @@ -39,7 +39,7 @@ class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion] ): Test = { implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) - val route: Route[IO] = AwsServerInterpreter.toRoute(e) + val route: Route[IO] = AwsCatsEffectServerInterpreter.toRoute(e) val name = e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix) Test(name)(runTest(stubBackend(route), uri"http://localhost:3000").unsafeRunSync()) } diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala index 1e13c599cd..618ee46089 100644 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaStubHttpTest.scala @@ -34,13 +34,13 @@ object AwsLambdaStubHttpTest { metricsInterceptor = metricsInterceptor, decodeFailureHandler = decodeFailureHandler.getOrElse(DefaultDecodeFailureHandler.handler) ) - AwsServerInterpreter.toRoute(e) + AwsCatsEffectServerInterpreter.toRoute(e) } override def routeRecoverErrors[I, E <: Throwable, O](e: Endpoint[I, E, O, Any], fn: I => IO[O])(implicit eClassTag: ClassTag[E] ): Route[IO] = { implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) - AwsServerInterpreter.toRouteRecoverErrors(e)(fn) + AwsCatsEffectServerInterpreter.toRouteRecoverErrors(e)(fn) } override def server(routes: NonEmptyList[Route[IO]]): Resource[IO, Port] = ??? } diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala new file mode 100644 index 0000000000..2d149464b4 --- /dev/null +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala @@ -0,0 +1,154 @@ +package sttp.tapir.serverless.aws.lambda.runtime + +import cats.effect.{ContextShift, IO, Resource} +import cats.syntax.all._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import sttp.client3._ +import sttp.client3.testing.SttpBackendStub +import sttp.model.{Header, StatusCode} +import sttp.tapir._ +import sttp.tapir.integ.cats.CatsMonadError +import sttp.tapir.serverless.aws.lambda.runtime.AwsLambdaRuntimeLoopTest._ +import sttp.tapir.serverless.aws.lambda.{AwsCatsEffectServerInterpreter, AwsServerOptions} + +import scala.concurrent.ExecutionContext.Implicits.global + +class AwsLambdaRuntimeLoopTest extends AnyFunSuite with Matchers { + + test("should process event") { + // given + var hello = "" + + val route = AwsCatsEffectServerInterpreter.toRoute(testEp.serverLogic { _ => + hello = "hello" + IO.pure(().asRight[Unit]) + }) + + val backend = SttpBackendStub(monadError) + .whenRequestMatches(_.uri == uri"http://aws/2018-06-01/runtime/invocation/next") + .thenRespondF(IO.pure(Response(awsRequest, StatusCode.Ok, "Ok", Seq(Header("lambda-runtime-aws-request-id", "43214"))))) + .whenAnyRequest + .thenRespondOk() + + // when + val result = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))).unsafeRunSync() + + // then + hello shouldBe "hello" + result shouldBe Right(()) + } + + test("should handle error while fetching event") { + // given + val route = AwsCatsEffectServerInterpreter.toRoute(testEp)(_ => IO(().asRight[Unit])) + + val backend = SttpBackendStub(monadError) + .whenRequestMatches(_.uri == uri"http://aws/2018-06-01/runtime/invocation/next") + .thenRespondF(_ => throw new RuntimeException) + + val loop = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))) + + // when + val result = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))).unsafeRunSync() + + // then + result.isLeft shouldBe true + } + + test("should handle decode failure") { + // given + val route = AwsCatsEffectServerInterpreter.toRoute(testEp)(_ => IO(().asRight[Unit])) + + val backend = SttpBackendStub(monadError) + .whenRequestMatches(_.uri == uri"http://aws/2018-06-01/runtime/invocation/next") + .thenRespondF(IO.pure(Response("???", StatusCode.Ok, "Ok", Seq(Header("lambda-runtime-aws-request-id", "43214"))))) + .whenAnyRequest + .thenRespondOk() + + // when + val result = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))).unsafeRunSync() + + // then + result.isLeft shouldBe true + } + + test("should handle missing lambda-runtime-aws-request-id header") { + // given + val route = AwsCatsEffectServerInterpreter.toRoute(testEp)(_ => IO(().asRight[Unit])) + + val backend = SttpBackendStub(monadError) + .whenRequestMatches(_.uri == uri"http://aws/2018-06-01/runtime/invocation/next") + .thenRespondF(IO.pure(Response(awsRequest, StatusCode.Ok))) + + // when + val result = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))).unsafeRunSync() + + // then + result.isLeft shouldBe true + } + + test("should handle error from server logic") { + // given + val route = AwsCatsEffectServerInterpreter.toRoute(testEp)(_ => throw new RuntimeException) + + val backend = SttpBackendStub(monadError) + .whenRequestMatches(_.uri == uri"http://aws/2018-06-01/runtime/invocation/next") + .thenRespondF(IO.pure(Response(awsRequest, StatusCode.Ok, "Ok", Seq(Header("lambda-runtime-aws-request-id", "43214"))))) + .whenAnyRequest + .thenRespondOk() + + // when + val result = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))).unsafeRunSync() + + // then + result shouldBe Right(()) + } + + test("should handle error when sending response to lambda") { + // given + val route = AwsCatsEffectServerInterpreter.toRoute(testEp)(_ => IO(().asRight[Unit])) + + val backend = SttpBackendStub(monadError) + .whenRequestMatches(_.uri == uri"http://aws/2018-06-01/runtime/invocation/next") + .thenRespondF(IO.pure(Response(awsRequest, StatusCode.Ok, "Ok", Seq(Header("lambda-runtime-aws-request-id", "43214"))))) + .whenAnyRequest + .thenRespondF(_ => throw new RuntimeException) + + // when + val result = AwsLambdaRuntimeLoop(route, "aws", Resource.eval(IO.pure(backend))).unsafeRunSync() + + // then + result.isLeft shouldBe true + } +} + +object AwsLambdaRuntimeLoopTest { + implicit val contextShift: ContextShift[IO] = IO.contextShift(global) + implicit val options: AwsServerOptions[IO] = AwsServerOptions.customInterceptors() + + val awsRequest: String = + """ + |{ + | "version": "2.0", + | "routeKey": "GET /api/hello", + | "rawPath": "/api/hello", + | "rawQueryString": "", + | "headers": {}, + | "requestContext": { + | "http": { + | "method": "GET", + | "path": "/api/hello", + | "protocol": "HTTP/1.1", + | "sourceIp": "188.146.66.23", + | "userAgent": "Chrome" + | } + | }, + | "isBase64Encoded": false + |} + |""".stripMargin + + val testEp: Endpoint[Unit, Unit, Unit, Any] = endpoint.get.in("api" / "hello") + + val monadError: CatsMonadError[IO] = new CatsMonadError[IO] +} diff --git a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala index 0fe8b86176..fb04b6d0c3 100644 --- a/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala +++ b/serverless/aws/sam/src/main/scala/sttp/tapir/serverless/aws/sam/model.scala @@ -9,10 +9,7 @@ case class SamTemplate( Resources: Map[String, Resource], Outputs: Map[String, Output] ) { - def toYaml: String = { - val template: SamTemplate = this - Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain).pretty(template.asJson) - } + def toYaml: String = Printer(dropNullKeys = true, preserveOrder = true, stringStyle = Printer.StringStyle.Plain).pretty(this.asJson) } sealed trait Resource { diff --git a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala index e5576cde20..e118a545ca 100644 --- a/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala +++ b/serverless/aws/terraform/src/main/scala/sttp/tapir/serverless/aws/terraform/model.scala @@ -5,10 +5,7 @@ import sttp.model.Method import sttp.tapir.serverless.aws.terraform.AwsTerraformEncoders._ case class AwsTerraformApiGateway(routes: Seq[AwsApiGatewayRoute]) { - def toJson()(implicit options: AwsTerraformOptions): String = { - val gateway = this - Printer.spaces2.print(gateway.asJson) - } + def toJson()(implicit options: AwsTerraformOptions): String = Printer.spaces2.print(this.asJson) } case class AwsApiGatewayRoute( From 38cdb0a06f2db701816bf851a1854e93da1d4266 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 31 May 2021 09:03:53 +0200 Subject: [PATCH 32/35] compile fix 2_12 --- .../aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala index 2d149464b4..b4813f4057 100644 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/runtime/AwsLambdaRuntimeLoopTest.scala @@ -14,6 +14,8 @@ import sttp.tapir.serverless.aws.lambda.{AwsCatsEffectServerInterpreter, AwsServ import scala.concurrent.ExecutionContext.Implicits.global +import scala.collection.immutable.Seq + class AwsLambdaRuntimeLoopTest extends AnyFunSuite with Matchers { test("should process event") { From 54587071e4538b7cdcff932a4d6eeb804d8a8d6a Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 31 May 2021 10:06:50 +0200 Subject: [PATCH 33/35] test backend update --- .../tapir/server/akkahttp/AkkaHttpServerTest.scala | 2 +- .../server/tests/DefaultCreateServerTest.scala | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala index 7ba06ce7e1..a3bf04f9df 100644 --- a/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala +++ b/server/akka-http-server/src/test/scala/sttp/tapir/server/akkahttp/AkkaHttpServerTest.scala @@ -37,7 +37,7 @@ class AkkaHttpServerTest extends TestSuite with EitherValues { implicit val m: FutureMonad = new FutureMonad()(actorSystem.dispatcher) val interpreter = new AkkaHttpTestServerInterpreter()(actorSystem) - val createServerTest = new DefaultCreateServerTest(backend, interpreter).asInstanceOf[DefaultCreateServerTest[Future, AkkaStreams with WebSockets, Route, AkkaResponseBody]] + val createServerTest = new DefaultCreateServerTest(backend, interpreter) def additionalTests(): List[Test] = List( Test("endpoint nested in a path directive") { diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala index ab683c61fa..a4051dfde4 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala @@ -16,7 +16,7 @@ import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import sttp.tapir.tests._ class DefaultCreateServerTest[F[_], +R, ROUTE, B]( - backend: SttpBackend[IO, R], + backend: SttpBackend[IO, Fs2Streams[IO] with WebSockets], interpreter: TestServerInterpreter[F, R, ROUTE, B] ) extends CreateServerTest[F, R, ROUTE, B] with StrictLogging { @@ -27,7 +27,7 @@ class DefaultCreateServerTest[F[_], +R, ROUTE, B]( metricsInterceptor: Option[MetricsRequestInterceptor[F, B]] = None )( fn: I => F[Either[E, O]] - )(runTest: (SttpBackend[IO, R], Uri) => IO[Assertion]): Test = { + )(runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion]): Test = { testServer( e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix), NonEmptyList.of(interpreter.route(e.serverLogic(fn), decodeFailureHandler, metricsInterceptor)) @@ -35,7 +35,7 @@ class DefaultCreateServerTest[F[_], +R, ROUTE, B]( } override def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, R, F], testNameSuffix: String = "")( - runTest: (SttpBackend[IO, R], Uri) => IO[Assertion] + runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] ): Test = { testServer( e.showDetail + (if (testNameSuffix == "") "" else " " + testNameSuffix), @@ -44,7 +44,7 @@ class DefaultCreateServerTest[F[_], +R, ROUTE, B]( } override def testServer(name: String, rs: => NonEmptyList[ROUTE])( - runTest: (SttpBackend[IO, R], Uri) => IO[Assertion] + runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] ): Test = { val resources = for { port <- interpreter.server(rs).onError { case e: Exception => @@ -73,11 +73,11 @@ trait CreateServerTest[F[_], +R, ROUTE, B] { testNameSuffix: String = "", decodeFailureHandler: Option[DecodeFailureHandler] = None, metricsInterceptor: Option[MetricsRequestInterceptor[F, B]] = None - )(fn: I => F[Either[E, O]])(runTest: (SttpBackend[IO, R], Uri) => IO[Assertion]): Test + )(fn: I => F[Either[E, O]])(runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion]): Test def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, R, F], testNameSuffix: String = "")( - runTest: (SttpBackend[IO, R], Uri) => IO[Assertion] + runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] ): Test - def testServer(name: String, rs: => NonEmptyList[ROUTE])(runTest: (SttpBackend[IO, R], Uri) => IO[Assertion]): Test + def testServer(name: String, rs: => NonEmptyList[ROUTE])(runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion]): Test } From 5a62a2f703e1bac9bd68eef656e99702e72c627c Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 31 May 2021 10:18:01 +0200 Subject: [PATCH 34/35] remove unused type alias --- .../sttp/tapir/server/tests/DefaultCreateServerTest.scala | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala index a4051dfde4..6639e31be6 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/DefaultCreateServerTest.scala @@ -63,10 +63,6 @@ class DefaultCreateServerTest[F[_], +R, ROUTE, B]( } } -object DefaultCreateServerTest { - type StreamsWithWebsockets = Fs2Streams[IO] with WebSockets -} - trait CreateServerTest[F[_], +R, ROUTE, B] { def testServer[I, E, O]( e: Endpoint[I, E, O, R], @@ -79,5 +75,7 @@ trait CreateServerTest[F[_], +R, ROUTE, B] { runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] ): Test - def testServer(name: String, rs: => NonEmptyList[ROUTE])(runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion]): Test + def testServer(name: String, rs: => NonEmptyList[ROUTE])( + runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] + ): Test } From bb2303235fc01f48a0c92bc18c0d693b6838e0aa Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 31 May 2021 11:02:45 +0200 Subject: [PATCH 35/35] longer wait for sam to start up (when downloading image) --- build.sbt | 2 +- .../lambda/AwsLambdaCreateServerStubTest.scala | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/build.sbt b/build.sbt index 74f30fc71d..1fa549484b 100644 --- a/build.sbt +++ b/build.sbt @@ -923,7 +923,7 @@ lazy val awsLambdaTests: ProjectMatrix = (projectMatrix in file("serverless/aws/ lazy val sam = Process("sam local start-api --warm-containers EAGER").run() Seq( Tests.Setup(() => { - val samReady = PollingUtils.poll(30.seconds, 1.second) { + val samReady = PollingUtils.poll(60.seconds, 1.second) { sam.isAlive() && PollingUtils.urlConnectionAvailable(new URL(s"http://127.0.0.1:3000/health")) } if (!samReady) { diff --git a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala index a436c95e17..5c8717be52 100644 --- a/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala +++ b/serverless/aws/lambda/src/test/scala/sttp/tapir/serverless/aws/lambda/AwsLambdaCreateServerStubTest.scala @@ -3,6 +3,8 @@ package sttp.tapir.serverless.aws.lambda import cats.data.NonEmptyList import cats.effect.IO import org.scalatest.Assertion +import sttp.capabilities.WebSockets +import sttp.capabilities.fs2.Fs2Streams import sttp.client3 import sttp.client3.testing.SttpBackendStub import sttp.client3.{ByteArrayBody, ByteBufferBody, InputStreamBody, NoBody, Request, Response, StringBody, SttpBackend, _} @@ -23,7 +25,7 @@ class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], testNameSuffix: String, decodeFailureHandler: Option[DecodeFailureHandler], metricsInterceptor: Option[MetricsRequestInterceptor[IO, String]] - )(fn: I => IO[Either[E, O]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { + )(fn: I => IO[Either[E, O]])(runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion]): Test = { implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors( encodeResponseBody = false, metricsInterceptor = metricsInterceptor, @@ -36,7 +38,7 @@ class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], } override def testServerLogic[I, E, O](e: ServerEndpoint[I, E, O, Any, IO], testNameSuffix: String)( - runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion] + runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] ): Test = { implicit val serverOptions: AwsServerOptions[IO] = AwsServerOptions.customInterceptors(encodeResponseBody = false) val route: Route[IO] = AwsCatsEffectServerInterpreter.toRoute(e) @@ -44,8 +46,10 @@ class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], Test(name)(runTest(stubBackend(route), uri"http://localhost:3000").unsafeRunSync()) } - override def testServer(name: String, rs: => NonEmptyList[Route[IO]])(runTest: (SttpBackend[IO, Any], Uri) => IO[Assertion]): Test = { - val backend: SttpBackendStub[IO, Any] = SttpBackendStub(catsMonadIO).whenAnyRequest + override def testServer(name: String, rs: => NonEmptyList[Route[IO]])( + runTest: (SttpBackend[IO, Fs2Streams[IO] with WebSockets], Uri) => IO[Assertion] + ): Test = { + val backend = SttpBackendStub[IO, Fs2Streams[IO] with WebSockets](catsMonadIO).whenAnyRequest .thenRespondF { request => val responses: NonEmptyList[Response[String]] = rs.map { route => route(sttpToAwsRequest(request)).map(awsToSttpResponse).unsafeRunSync() @@ -55,8 +59,8 @@ class AwsLambdaCreateServerStubTest extends CreateServerTest[IO, Any, Route[IO], Test(name)(runTest(backend, uri"http://localhost:3000").unsafeRunSync()) } - private def stubBackend(route: Route[IO]): SttpBackend[IO, Any] = - SttpBackendStub(catsMonadIO).whenAnyRequest.thenRespondF { request => + private def stubBackend(route: Route[IO]): SttpBackend[IO, Fs2Streams[IO] with WebSockets] = + SttpBackendStub[IO, Fs2Streams[IO] with WebSockets](catsMonadIO).whenAnyRequest.thenRespondF { request => route(sttpToAwsRequest(request)).map(awsToSttpResponse) } }