diff --git a/.jvmopts b/.jvmopts new file mode 100644 index 00000000..3f0aee11 --- /dev/null +++ b/.jvmopts @@ -0,0 +1,3 @@ +-Xms1G +-Xmx4G +-XX:+UseG1GC diff --git a/build.sbt b/build.sbt index 81b5db1f..a0152cd7 100644 --- a/build.sbt +++ b/build.sbt @@ -63,6 +63,7 @@ val fs2Version = "3.2.3" val http4sVersion = "0.23.7" val natchezVersion = "0.1.5" val munitVersion = "0.7.29" +val munitCEVersion = "1.0.7" lazy val root = project @@ -132,10 +133,11 @@ lazy val lambdaHttp4s = crossProject(JSPlatform, JVMPlatform) .settings( name := "feral-lambda-http4s", libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-server" % http4sVersion + "org.http4s" %%% "http4s-server" % http4sVersion, + "org.typelevel" %%% "munit-cats-effect-3" % "1.0.7" % Test ) ) - .dependsOn(lambda) + .dependsOn(lambda % "compile->compile;test->test") lazy val lambdaCloudFormationCustomResource = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) diff --git a/lambda-http4s/src/main/scala/feral/lambda/ApiGatewayProxyHandler.scala b/lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala similarity index 56% rename from lambda-http4s/src/main/scala/feral/lambda/ApiGatewayProxyHandler.scala rename to lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala index 7d8545d5..4988b691 100644 --- a/lambda-http4s/src/main/scala/feral/lambda/ApiGatewayProxyHandler.scala +++ b/lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala @@ -15,6 +15,7 @@ */ package feral.lambda +package http4s import cats.effect.kernel.Concurrent import cats.syntax.all._ @@ -22,13 +23,14 @@ import feral.lambda.events.ApiGatewayProxyEventV2 import feral.lambda.events.ApiGatewayProxyStructuredResultV2 import fs2.Stream import org.http4s.Charset -import org.http4s.Header import org.http4s.Headers import org.http4s.HttpRoutes import org.http4s.Method import org.http4s.Request import org.http4s.Response import org.http4s.Uri +import org.http4s.headers.Cookie +import org.http4s.headers.`Set-Cookie` object ApiGatewayProxyHandler { @@ -38,8 +40,11 @@ object ApiGatewayProxyHandler { for { event <- env.event method <- Method.fromString(event.requestContext.http.method).liftTo[F] - uri <- Uri.fromString(event.rawPath).liftTo[F] - headers = Headers(event.headers.toList) + uri <- Uri.fromString(event.rawPath + "?" + event.rawQueryString).liftTo[F] + cookies = Some(event.cookies) + .filter(_.nonEmpty) + .map(Cookie.name.toString -> _.mkString("; ")) + headers = Headers(cookies.toList ::: event.headers.toList) readBody = if (event.isBase64Encoded) fs2.text.base64.decode[F] @@ -56,20 +61,39 @@ object ApiGatewayProxyHandler { response.body.through(fs2.text.base64.encode) else response.body.through(fs2.text.utf8.decode)).compile.foldMonoid - } yield Some( - ApiGatewayProxyStructuredResultV2( - response.status.code, - response - .headers - .headers - .map { - case Header.Raw(name, value) => - name.toString -> value - } - .toMap, - responseBody, - isBase64Encoded + } yield { + val headers = response.headers.headers.groupMap(_.name)(_.value) + Some( + ApiGatewayProxyStructuredResultV2( + response.status.code, + (headers - `Set-Cookie`.name).map { + case (name, values) => + name.toString -> values.mkString(",") + }, + responseBody, + isBase64Encoded, + headers.getOrElse(`Set-Cookie`.name, Nil) + ) ) - ) + } + private[http4s] def decodeEvent[F[_]: Concurrent]( + event: ApiGatewayProxyEventV2): F[Request[F]] = for { + method <- Method.fromString(event.requestContext.http.method).liftTo[F] + uri <- Uri.fromString(event.rawPath + "?" + event.rawQueryString).liftTo[F] + cookies = Some(event.cookies) + .filter(_.nonEmpty) + .map(Cookie.name.toString -> _.mkString("; ")) + headers = Headers(cookies.toList ::: event.headers.toList) + readBody = + if (event.isBase64Encoded) + fs2.text.base64.decode[F] + else + fs2.text.utf8.encode[F] + } yield Request( + method, + uri, + headers = headers, + body = Stream.fromOption[F](event.body).through(readBody) + ) } diff --git a/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala b/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala new file mode 100644 index 00000000..1e2a0e44 --- /dev/null +++ b/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.lambda.http4s + +import cats.effect.IO +import cats.syntax.all._ +import feral.lambda.events.ApiGatewayProxyEventV2 +import feral.lambda.events.ApiGatewayProxyEventV2Suite +import munit.CatsEffectSuite +import org.http4s.Method +import org.http4s.syntax.all._ +import org.http4s.Headers + +class ApiGatewayProxyHandlerSuite extends CatsEffectSuite { + + import ApiGatewayProxyEventV2Suite._ + + test("decode event") { + for { + event <- event.as[ApiGatewayProxyEventV2].liftTo[IO] + request <- ApiGatewayProxyHandler.decodeEvent[IO](event) + _ <- IO(assertEquals(request.method, Method.GET)) + _ <- IO(assertEquals(request.uri, uri"/default/nodejs-apig-function-1G3XMPLZXVXYI?")) + _ <- IO(assertEquals(request.headers, expectedHeaders)) + _ <- request.body.compile.count.assertEquals(0L) + } yield () + } + + def expectedHeaders = Headers( + ("cookie", "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2; regStatus=pre-register"), + ("x-forwarded-port", "443"), + ( + "accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"), + ("sec-fetch-site", "cross-site"), + ("x-amzn-trace-id", "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e"), + ("sec-fetch-mode", "navigate"), + ("sec-fetch-user", "?1"), + ("accept-encoding", "gzip, deflate, br"), + ("x-forwarded-for", "205.255.255.176"), + ("upgrade-insecure-requests", "1"), + ( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36"), + ("accept-language", "en-US,en;q=0.9"), + ("x-forwarded-proto", "https"), + ("host", "r3pmxmplak.execute-api.us-east-2.amazonaws.com"), + ("content-length", "0"), + ("sec-fetch-dest", "document") + ) + +} diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala index 6e951e43..9870ebd4 100644 --- a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala +++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala @@ -31,10 +31,10 @@ object RequestContext { Decoder.forProduct1("http")(RequestContext.apply) } -// TODO Just the bare minimum for proof-of-concept final case class ApiGatewayProxyEventV2( rawPath: String, rawQueryString: String, + cookies: List[String], headers: Map[String, String], requestContext: RequestContext, body: Option[String], @@ -42,9 +42,10 @@ final case class ApiGatewayProxyEventV2( ) object ApiGatewayProxyEventV2 { - implicit def decoder: Decoder[ApiGatewayProxyEventV2] = Decoder.forProduct6( + implicit def decoder: Decoder[ApiGatewayProxyEventV2] = Decoder.forProduct7( "rawPath", "rawQueryString", + "cookies", "headers", "requestContext", "body", diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala index 776d66bb..477bb5e4 100644 --- a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala +++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala @@ -24,14 +24,16 @@ final case class ApiGatewayProxyStructuredResultV2( statusCode: Int, headers: Map[String, String], body: String, - isBase64Encoded: Boolean + isBase64Encoded: Boolean, + cookies: List[String] ) extends ApiGatewayProxyResultV2 object ApiGatewayProxyStructuredResultV2 { - implicit def encoder: Encoder[ApiGatewayProxyStructuredResultV2] = Encoder.forProduct4( + implicit def encoder: Encoder[ApiGatewayProxyStructuredResultV2] = Encoder.forProduct5( "statusCode", "headers", "body", - "isBase64Encoded" - )(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded)) + "isBase64Encoded", + "cookies" + )(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded, r.cookies)) } diff --git a/lambda/shared/src/test/scala-2/feral/lambda/events/ApiGatewayProxyEventV2Suite.scala b/lambda/shared/src/test/scala-2/feral/lambda/events/ApiGatewayProxyEventV2Suite.scala index 6625c64b..248e48d9 100644 --- a/lambda/shared/src/test/scala-2/feral/lambda/events/ApiGatewayProxyEventV2Suite.scala +++ b/lambda/shared/src/test/scala-2/feral/lambda/events/ApiGatewayProxyEventV2Suite.scala @@ -21,10 +21,16 @@ import munit.FunSuite class ApiGatewayProxyEventV2Suite extends FunSuite { + import ApiGatewayProxyEventV2Suite._ + test("decoder") { event.as[ApiGatewayProxyEventV2].toTry.get } +} + +object ApiGatewayProxyEventV2Suite { + def event = json""" { "version": "2.0",