diff --git a/.scalafmt.conf b/.scalafmt.conf index 086aa8f..3d6bc1b 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -21,5 +21,3 @@ rewrite.rules = [RedundantBraces, SortImports, PreferCurlyFors] docstrings = JavaDoc importSelectors = singleLine - -newlines.alwaysBeforeTopLevelStatements = true diff --git a/build.sbt b/build.sbt index d44ed59..4e22b69 100644 --- a/build.sbt +++ b/build.sbt @@ -1,9 +1,32 @@ lazy val commonSettings = Seq( organization := "io.github.howardjohn", - scalaVersion := "2.12.4" + scalaVersion := "2.12.6" ) -lazy val core = project +lazy val root = project + .in(file(".")) + .settings(commonSettings) + .settings(noPublishSettings) + .aggregate(http4s, exampleHttp4s) + +lazy val CirceVersion = "0.9.0" +lazy val ScalaTestVersion = "3.0.4" + +lazy val common = project + .in(file("common")) + .settings(commonSettings) + .settings(noPublishSettings) + .settings( + moduleName := "common", + libraryDependencies ++= + Seq( + "io.circe" %% "circe-generic" % CirceVersion, + "io.circe" %% "circe-parser" % CirceVersion, + "org.scalatest" %% "scalatest" % ScalaTestVersion % "test" + ) + ) + +lazy val http4s = project .in(file("http4s-lambda")) .settings(publishSettings) .settings(commonSettings) @@ -13,37 +36,31 @@ lazy val core = project moduleName := "http4s-lambda", scalacOptions ++= Seq("-Ypartial-unification"), libraryDependencies ++= { - val Http4sVersion = "0.18.0-M8" - val CirceVersion = "0.9.0" + val Http4sVersion = "0.18.10" Seq( "org.http4s" %% "http4s-core" % Http4sVersion, "org.http4s" %% "http4s-circe" % Http4sVersion, "io.circe" %% "circe-parser" % CirceVersion, "io.circe" %% "circe-generic" % CirceVersion, - "org.scalatest" %% "scalatest" % "3.0.4" % "test", + "org.scalatest" %% "scalatest" % ScalaTestVersion % "test", "org.http4s" %% "http4s-dsl" % Http4sVersion % "test" ) } ) + .dependsOn(common) -lazy val example = project - .in(file("example")) +lazy val exampleHttp4s = project + .in(file("example-http4s")) .settings(noPublishSettings) .settings(commonSettings) .settings( - moduleName := "example", - assemblyJarName in assembly := "example.jar", + moduleName := "example-http4s", + assemblyJarName in assembly := "example-http4s.jar", libraryDependencies ++= Seq( "org.http4s" %% "http4s-dsl" % "0.18.0-M8" ) ) - .dependsOn(core) - -lazy val root = project - .in(file(".")) - .settings(commonSettings) - .settings(noPublishSettings) - .aggregate(core, example) + .dependsOn(http4s) lazy val noPublishSettings = Seq( publish := {}, diff --git a/common/src/main/scala/io/github/howardjohn/lambda/Encoding.scala b/common/src/main/scala/io/github/howardjohn/lambda/Encoding.scala new file mode 100644 index 0000000..904b377 --- /dev/null +++ b/common/src/main/scala/io/github/howardjohn/lambda/Encoding.scala @@ -0,0 +1,41 @@ +package io.github.howardjohn.lambda + +import io.circe +import io.circe.generic.auto._ +import io.circe.parser.decode +import io.circe.syntax._ + +object Encoding { + case class ProxyRequest( + httpMethod: String, + path: String, + headers: Option[Map[String, String]], + body: Option[String], + queryStringParameters: Option[Map[String, String]] + ) + + case class ProxyResponse( + statusCode: Int, + headers: Map[String, String], + body: String + ) + + def parseRequest(rawInput: String): Either[circe.Error, ProxyRequest] = decode[ProxyRequest](rawInput) + + def encodeResponse(response: ProxyResponse): String = + response.asJson.noSpaces + + def reconstructPath(request: ProxyRequest): String = { + val requestString = request.queryStringParameters + .map { + _.map { + case (k, v) => s"$k=$v" + }.mkString("&") + } + .map { qs => + if (qs.isEmpty) "" else "?" + qs + } + .getOrElse("") + request.path + requestString + } +} diff --git a/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/IOStreamOps.scala b/common/src/main/scala/io/github/howardjohn/lambda/IOStreamOps.scala similarity index 70% rename from http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/IOStreamOps.scala rename to common/src/main/scala/io/github/howardjohn/lambda/IOStreamOps.scala index 67fbdd7..6793a46 100644 --- a/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/IOStreamOps.scala +++ b/common/src/main/scala/io/github/howardjohn/lambda/IOStreamOps.scala @@ -1,29 +1,23 @@ -package io.github.howardjohn.http4s.lambda +package io.github.howardjohn.lambda import java.io.{InputStream, OutputStream} import java.nio.charset.StandardCharsets -import cats.effect.IO - import scala.io.Source -object IOStreamOps { +object StreamOps { implicit class InputStreamOps(val is: InputStream) extends AnyVal { - - def consume(): IO[String] = IO { + def consume(): String = { val contents = Source.fromInputStream(is).mkString is.close() contents } - } implicit class OutputStreamOps(val os: OutputStream) extends AnyVal { - - def writeAndClose(contents: String): IO[Unit] = IO { + def writeAndClose(contents: String): Unit = { os.write(contents.getBytes(StandardCharsets.UTF_8)) os.close() } - } } diff --git a/common/src/main/scala/io/github/howardjohn/lambda/LambdaHandler.scala b/common/src/main/scala/io/github/howardjohn/lambda/LambdaHandler.scala new file mode 100644 index 0000000..b9e78ab --- /dev/null +++ b/common/src/main/scala/io/github/howardjohn/lambda/LambdaHandler.scala @@ -0,0 +1,21 @@ +package io.github.howardjohn.lambda + +import java.io.{InputStream, OutputStream} + +import io.github.howardjohn.lambda.Encoding._ +import io.github.howardjohn.lambda.StreamOps._ + +trait LambdaHandler { + def handleRequest(request: ProxyRequest): ProxyResponse + + def handle(is: InputStream, os: OutputStream): Unit = { + val rawInput = is.consume() + val request = parseRequest(rawInput).fold( + e => throw e, + identity + ) + val rawResponse = handleRequest(request) + val response = encodeResponse(rawResponse) + os.writeAndClose(response) + } +} diff --git a/example-http4s/serverless.yml b/example-http4s/serverless.yml new file mode 100644 index 0000000..821d6f0 --- /dev/null +++ b/example-http4s/serverless.yml @@ -0,0 +1,25 @@ +service: example-http4s + +provider: + name: aws + runtime: java8 + region: us-west-2 + stage: dev + +package: + artifact: target/scala-2.12/example-http4s.jar + +functions: + api: + handler: io.github.howardjohn.lambda.http4s.example.Route$EntryPoint::handle + events: + - http: + path: "{proxy+}" + method: any + cors: true +# Uncomment below to keep the application warm +# - schedule: +# rate: rate(4 minutes) +# input: +# httpMethod: GET +# path: /hello/keepWarm diff --git a/example/src/main/scala/io/github/howardjohn/http4s/lambda/example/Route.scala b/example-http4s/src/main/scala/io/github/howardjohn/lambda/http4s/example/Route.scala similarity index 73% rename from example/src/main/scala/io/github/howardjohn/http4s/lambda/example/Route.scala rename to example-http4s/src/main/scala/io/github/howardjohn/lambda/http4s/example/Route.scala index f278725..659aaa7 100644 --- a/example/src/main/scala/io/github/howardjohn/http4s/lambda/example/Route.scala +++ b/example-http4s/src/main/scala/io/github/howardjohn/lambda/http4s/example/Route.scala @@ -1,14 +1,14 @@ -package io.github.howardjohn.http4s.lambda.example +package io.github.howardjohn.lambda.http4s.example import cats.effect.IO +import io.github.howardjohn.lambda.http4s.Http4sLambdaHandler +import org.http4s.HttpService +import org.http4s.circe.jsonOf import io.circe.generic.auto._ -import io.github.howardjohn.http4s.lambda.LambdaHandler -import org.http4s._ -import org.http4s.circe._ import org.http4s.dsl.io._ object Route { - implicit val InputDecoder = jsonOf[IO, Input] + implicit val inputDecoder = jsonOf[IO, Input] // Set up the route val service: HttpService[IO] = HttpService[IO] { @@ -23,7 +23,7 @@ object Route { // Define the entry point for Lambda // Referenced as ` io.github.howardjohn.http4s.lambda.example$Route::handler` in Lambda - class EntryPoint extends LambdaHandler(service) + class EntryPoint extends Http4sLambdaHandler(service) case class Input( name: String, diff --git a/example/serverless.yml b/example/serverless.yml deleted file mode 100644 index 8321e5e..0000000 --- a/example/serverless.yml +++ /dev/null @@ -1,24 +0,0 @@ -service: example - -provider: - name: aws - runtime: java8 - region: us-west-2 - stage: dev - -package: - artifact: target/scala-2.12/example.jar - -functions: - api: - handler: io.github.howardjohn.http4s.lambda.example.Route$EntryPoint::handle - events: - - http: - path: "{proxy+}" - method: any - cors: true - - schedule: - rate: rate(4 minutes) - input: - httpMethod: GET - path: /hello/keepWarm diff --git a/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/Encoding.scala b/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/Encoding.scala deleted file mode 100644 index 029f242..0000000 --- a/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/Encoding.scala +++ /dev/null @@ -1,69 +0,0 @@ -package io.github.howardjohn.http4s.lambda - -import cats.effect.IO -import fs2.{text, Stream} -import io.circe.generic.auto._ -import io.circe.parser.decode -import io.circe.syntax._ -import org.http4s.{EmptyBody, Header, Headers, Method, ParseFailure, Request, Uri} - -object Encoding { - - case class ProxyRequest( - httpMethod: String, - path: String, - headers: Option[Map[String, String]], - body: Option[String], - queryStringParameters: Option[Map[String, String]] - ) - - case class ProxyResponse( - statusCode: Int, - headers: Map[String, String], - body: String - ) - - def decodeRequest(rawInput: String): Either[Exception, Request[IO]] = - for { - input <- decode[ProxyRequest](rawInput) - request <- parseRequest(input) - } yield request - - def encodeResponse(response: ProxyResponse): String = - response.asJson.noSpaces - - private def parseRequest(request: ProxyRequest): Either[ParseFailure, Request[IO]] = - for { - uri <- Uri.fromString(reconstructPath(request)) - method <- Method.fromString(request.httpMethod) - } yield - Request[IO]( - method, - uri, - headers = request.headers.map(toHeaders).getOrElse(Headers.empty), - body = request.body.map(encodeBody).getOrElse(EmptyBody) - ) - - private def toHeaders(headers: Map[String, String]): Headers = - Headers { - headers.map { - case (k, v) => Header(k, v) - }.toList - } - - private def encodeBody(body: String) = Stream(body).through(text.utf8Encode) - - private def reconstructPath(request: ProxyRequest): String = { - val requestString = request.queryStringParameters - .map { - _.map { - case (k, v) => s"$k=$v" - }.mkString("&") - } - .map { qs => - if (qs.isEmpty) "" else "?" + qs - } - .getOrElse("") - request.path + requestString - } -} diff --git a/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/LambdaHandler.scala b/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/LambdaHandler.scala deleted file mode 100644 index 07414aa..0000000 --- a/http4s-lambda/src/main/scala/io/github/howardjohn/http4s/lambda/LambdaHandler.scala +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.howardjohn.http4s.lambda - -import java.io.{InputStream, OutputStream} - -import cats.effect.IO -import io.github.howardjohn.http4s.lambda.Encoding._ -import io.github.howardjohn.http4s.lambda.IOStreamOps._ -import org.http4s._ - -class LambdaHandler(service: HttpService[IO]) { - - def handle(is: InputStream, os: OutputStream): Unit = { - val result = for { - input <- is.consume() - request <- IO.fromEither(decodeRequest(input)) - response <- runRequest(request) - rawResponse = encodeResponse(response) - _ <- os.writeAndClose(rawResponse) - } yield () - - result.unsafeRunSync() - } - - private def runRequest(request: Request[IO]): IO[ProxyResponse] = - service - .run(request) - .getOrElse(Response.notFound) - .flatMap(asProxyResponse) - - private def asProxyResponse(resp: Response[IO]): IO[ProxyResponse] = - resp - .as[String] - .map { body => - ProxyResponse( - resp.status.code, - resp.headers - .map(h => h.name.value -> h.value) - .toMap, - body) - } -} diff --git a/http4s-lambda/src/main/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandler.scala b/http4s-lambda/src/main/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandler.scala new file mode 100644 index 0000000..da8331a --- /dev/null +++ b/http4s-lambda/src/main/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandler.scala @@ -0,0 +1,61 @@ +package io.github.howardjohn.lambda.http4s + +import cats.effect.{Effect, IO} +import fs2.{text, Stream} +import io.github.howardjohn.lambda.Encoding._ +import io.github.howardjohn.lambda.{Encoding, LambdaHandler} +import org.http4s._ + +class Http4sLambdaHandler(service: HttpService[IO]) extends LambdaHandler { + import Http4sLambdaHandler._ + + override def handleRequest(request: ProxyRequest): ProxyResponse = + parseRequest(request) + .map(runRequest) + .fold( + err => throw err, + response => response.unsafeRunSync() + ) + + private def runRequest(request: Request[IO]): IO[ProxyResponse] = + service + .run(request) + .getOrElse(Response.notFound) + .flatMap(asProxyResponse) + +} + +private object Http4sLambdaHandler { + private def asProxyResponse(resp: Response[IO]): IO[ProxyResponse] = + resp + .as[String] + .map { body => + ProxyResponse( + resp.status.code, + resp.headers + .map(h => h.name.value -> h.value) + .toMap, + body) + } + + private def parseRequest(request: ProxyRequest): Either[ParseFailure, Request[IO]] = + for { + uri <- Uri.fromString(Encoding.reconstructPath(request)) + method <- Method.fromString(request.httpMethod) + } yield + Request[IO]( + method, + uri, + headers = request.headers.map(toHeaders).getOrElse(Headers.empty), + body = request.body.map(encodeBody).getOrElse(EmptyBody) + ) + + private def toHeaders(headers: Map[String, String]): Headers = + Headers { + headers.map { + case (k, v) => Header(k, v) + }.toList + } + + private def encodeBody(body: String) = Stream(body).through(text.utf8Encode) +} diff --git a/http4s-lambda/src/test/scala/io/github/howardjohn/http4s/lambda/EncodingSpec.scala b/http4s-lambda/src/test/scala/io/github/howardjohn/http4s/lambda/EncodingSpec.scala deleted file mode 100644 index a4c88f5..0000000 --- a/http4s-lambda/src/test/scala/io/github/howardjohn/http4s/lambda/EncodingSpec.scala +++ /dev/null @@ -1,56 +0,0 @@ -package io.github.howardjohn.http4s.lambda - -import cats.effect.IO -import io.circe.generic.auto._ -import io.circe.syntax._ -import io.github.howardjohn.http4s.lambda.Encoding._ -import org.http4s.Uri.uri -import org.http4s._ -import org.scalatest._ - -class EncodingSpec extends FlatSpec with Matchers { - import EncodingSpec._ - "decodeRequest" should "map empty values" in { - val req = runReq(ProxyRequest("GET", "/api", None, None, None)) - assert(req.headers == Headers.empty) - assert(req.body == EmptyBody) - } - - it should "map http method" in { - val req = runReq(ProxyRequest("GET", "/api", None, None, None)) - assert(req.method == Method.GET) - } - - it should "map body" in { - val req = runReq(ProxyRequest("GET", "/api", None, Some("body"), None)) - assert(req.body.compile.toList.unsafeRunSync == "body".getBytes.toSeq) - } - - it should "combine url and empty query parameters" in { - val req = runReq(ProxyRequest("GET", "/api", None, None, Some(Map()))) - assert(req.uri == uri("/api")) - } - - it should "combine url and one query parameter" in { - val req = runReq(ProxyRequest("GET", "/api", None, None, Some(Map("a" -> "b")))) - assert(req.uri == uri("/api?a=b")) - } - - it should "combine url and many query parameters" in { - val req = runReq(ProxyRequest("GET", "/api", None, None, Some(Map("a" -> "b", "c" -> "d")))) - assert(req.uri == uri("/api?a=b&c=d")) - } - - it should "map headers" in { - val req = runReq(ProxyRequest("GET", "/api", Some(Map("a" -> "b", "c" -> "d")), None, None)) - assert(req.method == Method.GET) - assert(req.headers == Headers(Header("a", "b"), Header("c", "d"))) - } -} - -object EncodingSpec { - - def runReq(req: ProxyRequest): Request[IO] = - decodeRequest(req.asJson.noSpaces).right.get - -} diff --git a/http4s-lambda/src/test/scala/io/github/howardjohn/http4s/lambda/LambdaHandlerSpec.scala b/http4s-lambda/src/test/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandlerSpec.scala similarity index 64% rename from http4s-lambda/src/test/scala/io/github/howardjohn/http4s/lambda/LambdaHandlerSpec.scala rename to http4s-lambda/src/test/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandlerSpec.scala index ceb951d..509949f 100644 --- a/http4s-lambda/src/test/scala/io/github/howardjohn/http4s/lambda/LambdaHandlerSpec.scala +++ b/http4s-lambda/src/test/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandlerSpec.scala @@ -1,26 +1,27 @@ -package io.github.howardjohn.http4s.lambda +package io.github.howardjohn.lambda.http4s import java.io.{ByteArrayInputStream, ByteArrayOutputStream, InputStream} import java.nio.charset.StandardCharsets import cats.effect.IO -import io.github.howardjohn.http4s.lambda.Encoding._ import io.circe.generic.auto._ import io.circe.parser.decode import io.circe.syntax._ +import io.github.howardjohn.lambda.Encoding.{ProxyRequest, ProxyResponse} import org.http4s._ import org.http4s.circe._ import org.http4s.dsl.io._ import org.scalatest.{FlatSpec, Matchers} -class LambdaHandlerSpec extends FlatSpec with Matchers { - import LambdaHandlerSpec._ +class Http4sLambdaHandlerSpec extends FlatSpec with Matchers { + import Http4sLambdaHandlerSpec._ + "handle" should "return the body with needed headers" in { val service: HttpService[IO] = HttpService[IO] { case _ => Ok("response") } - val response = doHandle(new LambdaHandler(service), ProxyRequest("GET", "/", None, None, None)) + val response = doHandle(new Http4sLambdaHandler(service), ProxyRequest("GET", "/", None, None, None)) assert(response.body == "response") assert(response.statusCode == 200) } @@ -29,7 +30,7 @@ class LambdaHandlerSpec extends FlatSpec with Matchers { case class Input( data: Seq[String], ) - implicit val InputDecoder = jsonOf[IO, Input] + implicit val inputDecoder = jsonOf[IO, Input] val service: HttpService[IO] = HttpService[IO] { case req @ POST -> Root => for { @@ -39,7 +40,7 @@ class LambdaHandlerSpec extends FlatSpec with Matchers { } val response = - doHandle(new LambdaHandler(service), ProxyRequest("POST", "/", None, Some("""{"data":["a","b"]}"""), None)) + doHandle(new Http4sLambdaHandler(service), ProxyRequest("POST", "/", None, Some("""{"data":["a","b"]}"""), None)) assert(response.body == "a") } @@ -48,20 +49,20 @@ class LambdaHandlerSpec extends FlatSpec with Matchers { case GET -> Root / "api" => Ok("Success") } - val response = doHandle(new LambdaHandler(service), ProxyRequest("GET", "/", None, None, None)) + val response = doHandle(new Http4sLambdaHandler(service), ProxyRequest("GET", "/", None, None, None)) assert(response.statusCode == 404) } } -object LambdaHandlerSpec { +object Http4sLambdaHandlerSpec { def toStream(source: String): InputStream = new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)) - def doHandle(handler: LambdaHandler, input: ProxyRequest): ProxyResponse = + def doHandle(handler: Http4sLambdaHandler, input: ProxyRequest): ProxyResponse = doHandle(handler, input.asJson.noSpaces) - def doHandle(handler: LambdaHandler, input: String): ProxyResponse = { + def doHandle(handler: Http4sLambdaHandler, input: String): ProxyResponse = { val os = new ByteArrayOutputStream handler.handle(toStream(input), os) decode[ProxyResponse](new String(os.toByteArray, "UTF-8")).right.get diff --git a/project/plugins.sbt b/project/plugins.sbt index 4c1476e..8a0d471 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1,2 @@ addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.0") +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.1")