├── .gitignore ├── project ├── build.properties ├── assembly.sbt └── plugins.sbt ├── .travis.yml ├── http4s-lambda └── src │ ├── main │ └── scala │ │ └── io │ │ └── github │ │ └── howardjohn │ │ └── lambda │ │ └── http4s │ │ ├── Http4sLambdaHandler.scala │ │ └── Http4sLambdaHandlerK.scala │ └── test │ └── scala │ └── io │ └── github │ └── howardjohn │ └── lambda │ └── http4s │ ├── Http4sLambdaHandlerSpec.scala │ └── TestRoutes.scala ├── example-http4s ├── src │ └── main │ │ └── scala │ │ └── io │ │ └── github │ │ └── howardjohn │ │ └── lambda │ │ └── http4s │ │ └── example │ │ └── Route.scala └── serverless.yml ├── .scalafmt.conf ├── example-akka-http ├── serverless.yml └── src │ └── main │ └── scala │ └── io │ └── github │ └── howardjohn │ └── lambda │ └── akka │ └── example │ └── Route.scala ├── common └── src │ ├── main │ └── scala │ │ └── io │ │ └── github │ │ └── howardjohn │ │ └── lambda │ │ ├── LambdaHandler.scala │ │ ├── IOStreamOps.scala │ │ └── ProxyEncoding.scala │ └── test │ └── scala │ └── io │ └── github │ └── howardjohn │ └── lambda │ └── ProxyEncodingSpec.scala ├── http4s-lambda-zio └── src │ ├── main │ └── scala │ │ └── io │ │ └── github │ │ └── howardjohn │ │ └── lambda │ │ └── http4szio │ │ └── Http4sLambdaHandlerZIO.scala │ └── test │ └── scala │ └── io │ └── github │ └── howardjohn │ └── lambda │ └── http4szio │ └── Http4sLambdaHandlerZIOSpec.scala ├── LICENSE ├── akka-http-lambda └── src │ ├── test │ └── scala │ │ └── io │ │ └── github │ │ └── howardjohn │ │ └── lambda │ │ └── akka │ │ └── AkkaHttpLambdaHandlerSpec.scala │ └── main │ └── scala │ └── io │ └── github │ └── howardjohn │ └── lambda │ └── akka │ └── AkkaHttpLambdaHandler.scala ├── README.md └── tests └── src └── main └── scala └── io └── github └── howardjohn └── lambda └── LambdaHandlerBehavior.scala /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.10 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | scala: 4 | - 2.12.10 5 | -------------------------------------------------------------------------------- /project/assembly.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6") 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.0") 2 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.1") 3 | -------------------------------------------------------------------------------- /http4s-lambda/src/main/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandler.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4s 2 | 3 | import cats.effect.IO 4 | import io.github.howardjohn.lambda.ProxyEncoding._ 5 | import org.http4s._ 6 | 7 | class Http4sLambdaHandler(val service: HttpRoutes[IO]) extends Http4sLambdaHandlerK[IO] { 8 | def handleRequest(request: ProxyRequest): ProxyResponse = 9 | parseRequest(request) 10 | .map(runRequest) 11 | .flatMap(_.attempt.unsafeRunSync()) 12 | .fold(errorResponse, identity) 13 | } 14 | -------------------------------------------------------------------------------- /example-http4s/src/main/scala/io/github/howardjohn/lambda/http4s/example/Route.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4s.example 2 | 3 | import cats.effect.IO 4 | import io.github.howardjohn.lambda.http4s.Http4sLambdaHandler 5 | import org.http4s.HttpService 6 | import org.http4s.dsl.io._ 7 | 8 | object Route { 9 | // Set up the route 10 | val service: HttpService[IO] = HttpService[IO] { 11 | case GET -> Root / "hello" / name => Ok(s"Hello, $name!") 12 | } 13 | 14 | // Define the entry point for Lambda 15 | class EntryPoint extends Http4sLambdaHandler(service) 16 | } 17 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | # http://scalameta.org/scalafmt/#Configuration for details 2 | 3 | maxColumn = 120 4 | align = none 5 | lineEndings = unix 6 | 7 | continuationIndent.callSite = 2 8 | continuationIndent.defnSite = 2 9 | 10 | newlines.sometimesBeforeColonInMethodReturnType = false 11 | spaces.afterKeywordBeforeParen = true 12 | 13 | includeCurlyBraceInSelectChains = true 14 | optIn.breakChainOnFirstMethodDot = true 15 | 16 | newlines.alwaysBeforeElseAfterCurlyIf = false 17 | 18 | binPack.literalArgumentLists = false 19 | 20 | rewrite.rules = [RedundantBraces, SortImports, PreferCurlyFors] 21 | 22 | docstrings = JavaDoc 23 | importSelectors = singleLine 24 | -------------------------------------------------------------------------------- /example-http4s/serverless.yml: -------------------------------------------------------------------------------- 1 | service: example-http4s 2 | 3 | provider: 4 | name: aws 5 | runtime: java8 6 | region: us-west-2 7 | stage: dev 8 | 9 | package: 10 | artifact: target/scala-2.12/example-http4s.jar 11 | 12 | functions: 13 | api: 14 | handler: io.github.howardjohn.lambda.http4s.example.Route$EntryPoint::handle 15 | events: 16 | - http: 17 | path: "{proxy+}" 18 | method: any 19 | cors: true 20 | # Uncomment below to keep the application warm 21 | # - schedule: 22 | # rate: rate(4 minutes) 23 | # input: 24 | # httpMethod: GET 25 | # path: /hello/keepWarm 26 | -------------------------------------------------------------------------------- /example-akka-http/serverless.yml: -------------------------------------------------------------------------------- 1 | service: example-akka-http 2 | 3 | provider: 4 | name: aws 5 | runtime: java8 6 | region: us-west-2 7 | stage: dev 8 | 9 | package: 10 | artifact: target/scala-2.12/example-akka-http.jar 11 | 12 | functions: 13 | api: 14 | handler: io.github.howardjohn.lambda.akka.example.Route$EntryPoint::handle 15 | events: 16 | - http: 17 | path: "{proxy+}" 18 | method: any 19 | cors: true 20 | # Uncomment below to keep the application warm 21 | # - schedule: 22 | # rate: rate(4 minutes) 23 | # input: 24 | # httpMethod: GET 25 | # path: /hello/keepWarm 26 | -------------------------------------------------------------------------------- /common/src/main/scala/io/github/howardjohn/lambda/LambdaHandler.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda 2 | 3 | import java.io.{InputStream, OutputStream} 4 | 5 | import io.github.howardjohn.lambda.ProxyEncoding._ 6 | import io.github.howardjohn.lambda.StreamOps._ 7 | 8 | trait LambdaHandler { 9 | def handleRequest(request: ProxyRequest): ProxyResponse 10 | 11 | def handle(is: InputStream, os: OutputStream): Unit = { 12 | val rawInput = is.consume() 13 | val request = parseRequest(rawInput).fold( 14 | e => throw e, 15 | identity 16 | ) 17 | val rawResponse = handleRequest(request) 18 | val response = encodeResponse(rawResponse) 19 | os.writeAndClose(response) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /common/src/main/scala/io/github/howardjohn/lambda/IOStreamOps.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda 2 | 3 | import java.io.{InputStream, OutputStream} 4 | import java.nio.charset.StandardCharsets 5 | 6 | import scala.io.Source 7 | 8 | object StreamOps { 9 | implicit class InputStreamOps(val is: InputStream) extends AnyVal { 10 | def consume(): String = { 11 | val contents = Source.fromInputStream(is).mkString 12 | is.close() 13 | contents 14 | } 15 | } 16 | 17 | implicit class OutputStreamOps(val os: OutputStream) extends AnyVal { 18 | def writeAndClose(contents: String): Unit = { 19 | os.write(contents.getBytes(StandardCharsets.UTF_8)) 20 | os.close() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /http4s-lambda-zio/src/main/scala/io/github/howardjohn/lambda/http4szio/Http4sLambdaHandlerZIO.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4szio 2 | 3 | import io.github.howardjohn.lambda.ProxyEncoding._ 4 | import io.github.howardjohn.lambda.http4s.Http4sLambdaHandlerK 5 | import org.http4s._ 6 | import zio._ 7 | import zio.interop.catz._ 8 | 9 | class Http4sLambdaHandlerZIO(val service: HttpRoutes[Task]) extends Http4sLambdaHandlerK[Task] { 10 | val runtime: DefaultRuntime = new DefaultRuntime {} 11 | 12 | def handleRequest(request: ProxyRequest): ProxyResponse = 13 | parseRequest(request) 14 | .map(runRequest) 15 | .flatMap(request => runtime.unsafeRun(request.either)) 16 | .fold(errorResponse, identity) 17 | } 18 | -------------------------------------------------------------------------------- /http4s-lambda/src/test/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandlerSpec.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4s 2 | 3 | import cats.effect.IO 4 | import io.circe.generic.auto._ 5 | import io.github.howardjohn.lambda.LambdaHandlerBehavior 6 | import io.github.howardjohn.lambda.LambdaHandlerBehavior._ 7 | import org.http4s.EntityDecoder 8 | import org.http4s.circe._ 9 | import org.scalatest.{FeatureSpec, GivenWhenThen} 10 | 11 | class Http4sLambdaHandlerSpec extends FeatureSpec with LambdaHandlerBehavior with GivenWhenThen { 12 | implicit val jsonDecoder: EntityDecoder[IO, JsonBody] = jsonOf[IO, JsonBody] 13 | 14 | val handler = new Http4sLambdaHandler(new TestRoutes[IO].routes) 15 | 16 | scenariosFor(behavior(handler)) 17 | } 18 | -------------------------------------------------------------------------------- /http4s-lambda-zio/src/test/scala/io/github/howardjohn/lambda/http4szio/Http4sLambdaHandlerZIOSpec.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4szio 2 | 3 | import io.circe.generic.auto._ 4 | import io.github.howardjohn.lambda.LambdaHandlerBehavior 5 | import io.github.howardjohn.lambda.LambdaHandlerBehavior._ 6 | import io.github.howardjohn.lambda.http4s.TestRoutes 7 | import org.http4s.circe._ 8 | import org.http4s.EntityDecoder 9 | import org.scalatest.{FeatureSpec, GivenWhenThen} 10 | import zio._ 11 | import zio.interop.catz._ 12 | import zio.interop.catz.implicits._ 13 | 14 | class Http4sLambdaHandlerZIOSpec extends FeatureSpec with LambdaHandlerBehavior with GivenWhenThen { 15 | implicit val jsonDecoder: EntityDecoder[Task, JsonBody] = jsonOf[Task, JsonBody] 16 | 17 | val handler = new Http4sLambdaHandlerZIO(new TestRoutes[Task].routes) 18 | 19 | scenariosFor(behavior(handler)) 20 | } 21 | -------------------------------------------------------------------------------- /example-akka-http/src/main/scala/io/github/howardjohn/lambda/akka/example/Route.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.akka.example 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.server.Directives._ 5 | import akka.http.scaladsl.server.Route 6 | import akka.stream.ActorMaterializer 7 | import io.github.howardjohn.lambda.akka.AkkaHttpLambdaHandler 8 | 9 | object Route { 10 | // Set up the route 11 | val route: Route = 12 | path("hello" / Segment) { name: String => 13 | get { 14 | complete(s"Hello, $name!") 15 | } 16 | } 17 | 18 | // Set up dependencies 19 | implicit val system: ActorSystem = ActorSystem("example") 20 | implicit val materializer: ActorMaterializer = ActorMaterializer() 21 | implicit val ec = scala.concurrent.ExecutionContext.Implicits.global 22 | 23 | // Define the entry point for Lambda 24 | class EntryPoint extends AkkaHttpLambdaHandler(route) 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 John Howard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /common/src/main/scala/io/github/howardjohn/lambda/ProxyEncoding.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda 2 | 3 | import io.circe 4 | import io.circe.generic.auto._ 5 | import io.circe.parser.decode 6 | import io.circe.syntax._ 7 | 8 | object ProxyEncoding { 9 | case class ProxyRequest( 10 | httpMethod: String, 11 | path: String, 12 | headers: Option[Map[String, String]], 13 | body: Option[String], 14 | queryStringParameters: Option[Map[String, String]] 15 | ) 16 | 17 | case class ProxyResponse( 18 | statusCode: Int, 19 | headers: Map[String, String], 20 | body: String 21 | ) 22 | 23 | def parseRequest(rawInput: String): Either[circe.Error, ProxyRequest] = decode[ProxyRequest](rawInput) 24 | 25 | def encodeResponse(response: ProxyResponse): String = 26 | response.asJson.noSpaces 27 | 28 | def reconstructPath(request: ProxyRequest): String = { 29 | val requestString = request.queryStringParameters 30 | .map { 31 | _.map { 32 | case (k, v) => s"$k=$v" 33 | }.mkString("&") 34 | } 35 | .map { qs => 36 | if (qs.isEmpty) "" else "?" + qs 37 | } 38 | .getOrElse("") 39 | request.path + requestString 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /http4s-lambda/src/test/scala/io/github/howardjohn/lambda/http4s/TestRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4s 2 | 3 | import cats.{Applicative, MonadError} 4 | import cats.effect.Sync 5 | import cats.implicits._ 6 | import io.github.howardjohn.lambda.LambdaHandlerBehavior 7 | import io.github.howardjohn.lambda.LambdaHandlerBehavior._ 8 | import org.http4s.dsl.Http4sDsl 9 | import org.http4s.{EntityDecoder, Header, HttpRoutes} 10 | import org.http4s.circe._ 11 | import io.circe.generic.auto._ 12 | import io.circe.syntax._ 13 | import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher 14 | 15 | class TestRoutes[F[_]] { 16 | 17 | object TimesQueryMatcher extends OptionalQueryParamDecoderMatcher[Int]("times") 18 | 19 | val dsl = Http4sDsl[F] 20 | 21 | import dsl._ 22 | 23 | def routes(implicit sync: Sync[F], 24 | jsonDecoder: EntityDecoder[F, JsonBody], 25 | me: MonadError[F, Throwable], 26 | stringDecoder: EntityDecoder[F, String], 27 | ap: Applicative[F]): HttpRoutes[F] = HttpRoutes.of[F] { 28 | case GET -> Root / "hello" :? TimesQueryMatcher(times) => 29 | Ok { 30 | Seq 31 | .fill(times.getOrElse(1))("Hello World!") 32 | .mkString(" ") 33 | } 34 | case GET -> Root / "long" => Applicative[F].pure(Thread.sleep(1000)).flatMap(_ => Ok("Hello World!")) 35 | case GET -> Root / "exception" => throw RouteException() 36 | case GET -> Root / "error" => InternalServerError() 37 | case req@GET -> Root / "header" => 38 | val header = req.headers.find(h => h.name.value == inputHeader).map(_.value).getOrElse("Header Not Found") 39 | Ok(header, Header(outputHeader, outputHeaderValue)) 40 | case req@POST -> Root / "post" => req.as[String].flatMap(s => Ok(s)) 41 | case req@POST -> Root / "json" => req.as[JsonBody].flatMap(s => Ok(LambdaHandlerBehavior.jsonReturn.asJson)) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /http4s-lambda/src/main/scala/io/github/howardjohn/lambda/http4s/Http4sLambdaHandlerK.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.http4s 2 | 3 | import cats.{Applicative, MonadError} 4 | import io.github.howardjohn.lambda.{LambdaHandler, ProxyEncoding} 5 | import io.github.howardjohn.lambda.ProxyEncoding.{ProxyRequest, ProxyResponse} 6 | import org.http4s._ 7 | import fs2.{Pure, Stream, text} 8 | import cats.implicits._ 9 | 10 | import scala.util.Try 11 | 12 | trait Http4sLambdaHandlerK[F[_]] extends LambdaHandler { 13 | val service: HttpRoutes[F] 14 | 15 | def handleRequest(request: ProxyRequest): ProxyResponse 16 | 17 | def runRequest(request: Request[F])(implicit F: MonadError[F, Throwable], 18 | decoder: EntityDecoder[F, String]): F[ProxyResponse] = 19 | Try { 20 | service 21 | .run(request) 22 | .getOrElse(Response.notFound) 23 | .flatMap(asProxyResponse) 24 | }.fold(errorResponse.andThen(e => Applicative[F].pure(e)), identity) 25 | 26 | protected val errorResponse = (err: Throwable) => ProxyResponse(500, Map.empty, err.getMessage) 27 | 28 | protected def asProxyResponse(resp: Response[F])(implicit F: MonadError[F, Throwable], 29 | decoder: EntityDecoder[F, String]): F[ProxyResponse] = 30 | resp 31 | .as[String] 32 | .map { body => 33 | ProxyResponse( 34 | resp.status.code, 35 | resp.headers.toList 36 | .map(h => h.name.value -> h.value) 37 | .toMap, 38 | body) 39 | } 40 | 41 | protected def parseRequest(request: ProxyRequest): Either[ParseFailure, Request[F]] = 42 | for { 43 | uri <- Uri.fromString(ProxyEncoding.reconstructPath(request)) 44 | method <- Method.fromString(request.httpMethod) 45 | } yield 46 | Request[F]( 47 | method, 48 | uri, 49 | headers = request.headers.map(toHeaders).getOrElse(Headers.empty), 50 | body = request.body.map(encodeBody).getOrElse(EmptyBody) 51 | ) 52 | 53 | protected def toHeaders(headers: Map[String, String]): Headers = 54 | Headers { 55 | headers.map { 56 | case (k, v) => Header(k, v) 57 | }.toList 58 | } 59 | 60 | protected def encodeBody(body: String): Stream[Pure, Byte] = Stream(body).through(text.utf8Encode) 61 | } 62 | 63 | -------------------------------------------------------------------------------- /common/src/test/scala/io/github/howardjohn/lambda/ProxyEncodingSpec.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda 2 | 3 | import io.github.howardjohn.lambda.ProxyEncoding._ 4 | import org.scalatest.FlatSpec 5 | 6 | class ProxyEncodingSpec extends FlatSpec { 7 | import ProxyEncodingSpec._ 8 | 9 | "reconstructPath" should "handle no parameters" in 10 | assert { 11 | reconstructPath(makeRequest(query = Some(Map.empty))) === path 12 | } 13 | 14 | it should "handle null parameters" in 15 | assert { 16 | reconstructPath(makeRequest(query = None)) === path 17 | } 18 | 19 | it should "handle one parameter" in 20 | assert { 21 | reconstructPath(makeRequest(query = Some(Map("param1" -> "value1")))) === s"$path?param1=value1" 22 | } 23 | 24 | it should "handle two parameters" in { 25 | val params = Map("param1" -> "value1", "param2" -> "value2") 26 | assert { 27 | reconstructPath(makeRequest(query = Some(params))) === s"$path?param1=value1¶m2=value2" 28 | } 29 | } 30 | 31 | "parseRequest" should "handle just a method and path" in { 32 | val raw = makeRawRequest(makeRequest()) 33 | assert { 34 | parseRequest(raw) === Right(makeRequest()) 35 | } 36 | } 37 | 38 | it should "handle just a query parameters" in { 39 | val request = makeRequest(query = Some(Map("param1" -> "option1"))) 40 | val raw = makeRawRequest(request) 41 | assert { 42 | parseRequest(raw) === Right(request) 43 | } 44 | } 45 | 46 | it should "handle multiple query parameters" in { 47 | val request = makeRequest(query = Some(Map("param1" -> "option1", "param2" -> "option2"))) 48 | val raw = makeRawRequest(request) 49 | assert { 50 | parseRequest(raw) === Right(request) 51 | } 52 | } 53 | 54 | it should "handle a body" in { 55 | val request = makeRequest(body = Some("some body")) 56 | val raw = makeRawRequest(request) 57 | assert { 58 | parseRequest(raw) === Right(request) 59 | } 60 | } 61 | } 62 | 63 | object ProxyEncodingSpec { 64 | val path = "www.example.com/" 65 | 66 | def makeRawRequest(request: ProxyRequest): String = 67 | s""" 68 | |{ 69 | | "httpMethod": "${request.httpMethod}", 70 | | "path": "${request.path}", 71 | | "body": ${request.body.map(b => "\"" + b + "\"").getOrElse("null")}, 72 | | "headers": ${request.headers.map(_.toSeq).map(pairsToJson).getOrElse("null")}, 73 | | "queryStringParameters": ${request.queryStringParameters.map(_.toSeq).map(pairsToJson).getOrElse("null")} 74 | |} 75 | """.stripMargin 76 | 77 | private def pairsToJson(pairs: Seq[(String, String)]): String = { 78 | val body = pairs 79 | .map { 80 | case (k, v) => s""""$k": "$v"""" 81 | } 82 | .mkString(",\n") 83 | s"{\n$body\n}" 84 | } 85 | 86 | def makeRequest( 87 | httpMethod: String = "GET", 88 | path: String = path, 89 | headers: Option[Map[String, String]] = None, 90 | body: Option[String] = None, 91 | query: Option[Map[String, String]] = None 92 | ) = ProxyRequest( 93 | httpMethod, 94 | path, 95 | headers, 96 | body, 97 | query 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /akka-http-lambda/src/test/scala/io/github/howardjohn/lambda/akka/AkkaHttpLambdaHandlerSpec.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.akka 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller} 5 | import akka.http.scaladsl.model.MediaTypes.`application/json` 6 | import akka.http.scaladsl.model.headers.RawHeader 7 | import akka.http.scaladsl.model.{HttpEntity, StatusCodes} 8 | import akka.http.scaladsl.server.Directives._ 9 | import akka.http.scaladsl.server.Route 10 | import akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, Unmarshaller} 11 | import akka.stream.ActorMaterializer 12 | import io.circe.parser.decode 13 | import io.circe.syntax._ 14 | import io.circe.{Decoder, Encoder} 15 | import io.github.howardjohn.lambda.LambdaHandlerBehavior 16 | import io.github.howardjohn.lambda.LambdaHandlerBehavior._ 17 | import org.scalatest.{BeforeAndAfterAll, FeatureSpec, GivenWhenThen} 18 | 19 | import scala.concurrent.Future 20 | 21 | class AkkaHttpLambdaHandlerSpec 22 | extends FeatureSpec 23 | with LambdaHandlerBehavior 24 | with GivenWhenThen 25 | with BeforeAndAfterAll { 26 | 27 | implicit val system: ActorSystem = ActorSystem("test") 28 | implicit val materializer: ActorMaterializer = ActorMaterializer() 29 | implicit val ec = scala.concurrent.ExecutionContext.Implicits.global 30 | 31 | val route: Route = 32 | path("hello") { 33 | (get & parameter("times".as[Int] ? 1)) { times => 34 | complete { 35 | Seq 36 | .fill(times)("Hello World!") 37 | .mkString(" ") 38 | } 39 | } 40 | } ~ path("long") { 41 | get { 42 | Thread.sleep(1000) 43 | complete("") 44 | } 45 | } ~ path("post") { 46 | post { 47 | entity(as[String]) { body => 48 | complete(body) 49 | } 50 | } 51 | } ~ path("json") { 52 | post { 53 | import CirceJsonMarshalling._ 54 | import io.circe.generic.auto._ 55 | entity(as[JsonBody]) { entity => 56 | complete(LambdaHandlerBehavior.jsonReturn.asJson) 57 | } 58 | } 59 | } ~ path("exception") { 60 | get { 61 | throw RouteException() 62 | } 63 | } ~ path("error") { 64 | get { 65 | complete(StatusCodes.InternalServerError) 66 | } 67 | } ~ path("header") { 68 | get { 69 | headerValueByName(inputHeader) { header => 70 | respondWithHeaders(RawHeader(outputHeader, outputHeaderValue)) { 71 | complete(header) 72 | } 73 | } 74 | } 75 | } 76 | 77 | val handler = new AkkaHttpLambdaHandler(route) 78 | 79 | scenariosFor(behavior(handler)) 80 | 81 | override def afterAll(): Unit = { 82 | materializer.shutdown() 83 | system.terminate() 84 | } 85 | } 86 | 87 | /** 88 | * Provides (un)marshallers for akka-http that are about the json content type 89 | */ 90 | object CirceJsonMarshalling { 91 | implicit final def unmarshaller[A: Decoder]: FromEntityUnmarshaller[A] = 92 | Unmarshaller.stringUnmarshaller 93 | .forContentTypes(`application/json`) 94 | .flatMap { ctx => mat => json => 95 | decode[A](json).fold(Future.failed, Future.successful) 96 | } 97 | 98 | implicit final def marshaller[A: Encoder]: ToEntityMarshaller[A] = 99 | Marshaller.withFixedContentType(`application/json`) { a => 100 | HttpEntity(`application/json`, a.asJson.noSpaces) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /akka-http-lambda/src/main/scala/io/github/howardjohn/lambda/akka/AkkaHttpLambdaHandler.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda.akka 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.model.HttpHeader.ParsingResult 5 | import akka.http.scaladsl.model._ 6 | import akka.http.scaladsl.server.Route 7 | import akka.http.scaladsl.unmarshalling.Unmarshal 8 | import akka.stream.ActorMaterializer 9 | import akka.stream.scaladsl.{Keep, Sink, Source} 10 | import io.github.howardjohn.lambda.ProxyEncoding._ 11 | import io.github.howardjohn.lambda.{LambdaHandler, ProxyEncoding} 12 | 13 | import scala.concurrent.duration.Duration 14 | import scala.concurrent.{Await, ExecutionContext, Future} 15 | 16 | class AkkaHttpLambdaHandler(route: Route)( 17 | implicit system: ActorSystem, 18 | materializer: ActorMaterializer, 19 | ec: ExecutionContext 20 | ) extends LambdaHandler { 21 | import AkkaHttpLambdaHandler._ 22 | 23 | override def handleRequest(request: ProxyRequest): ProxyResponse = 24 | Await.result(runRequest(proxyToAkkaRequest(request)), Duration.Inf) 25 | 26 | private def runRequest(request: HttpRequest): Future[ProxyResponse] = { 27 | val source = Source.single(request) 28 | val sink = Sink.head[HttpResponse] 29 | source 30 | .via(route) 31 | .toMat(sink)(Keep.right) 32 | .run() 33 | .flatMap(asProxyResponse) 34 | } 35 | 36 | private def proxyToAkkaRequest(request: ProxyRequest): HttpRequest = 37 | new HttpRequest( 38 | method = parseHttpMethod(request.httpMethod), 39 | uri = Uri(ProxyEncoding.reconstructPath(request)), 40 | headers = parseRequestHeaders(request.headers.getOrElse(Map.empty)), 41 | entity = parseEntity(request.headers.getOrElse(Map.empty), request.body), 42 | protocol = HttpProtocols.`HTTP/1.1` 43 | ) 44 | 45 | private def parseEntity(headers: Map[String, String], body: Option[String]): MessageEntity = { 46 | val defaultContentType = ContentTypes.`text/plain(UTF-8)` 47 | val contentType = ContentType 48 | .parse(headers.getOrElse("Content-Type", defaultContentType.value)) 49 | .getOrElse(defaultContentType) 50 | 51 | body match { 52 | case Some(b) => HttpEntity(contentType, b.getBytes) 53 | case None => HttpEntity.empty(contentType) 54 | } 55 | } 56 | 57 | private def asProxyResponse(resp: HttpResponse): Future[ProxyResponse] = 58 | Unmarshal(resp.entity) 59 | .to[String] 60 | .map { body => 61 | ProxyResponse( 62 | resp.status.intValue(), 63 | resp.headers.map(h => h.name -> h.value).toMap, 64 | body 65 | ) 66 | } 67 | } 68 | 69 | private object AkkaHttpLambdaHandler { 70 | private def parseRequestHeaders(headers: Map[String, String]): List[HttpHeader] = 71 | headers.map { 72 | case (k, v) => 73 | HttpHeader.parse(k, v) match { 74 | case ParsingResult.Ok(header, _) => header 75 | case ParsingResult.Error(err) => throw new RuntimeException(s"Failed to parse header $k:$v with error $err.") 76 | } 77 | }.toList 78 | 79 | private def parseHttpMethod(method: String) = method.toUpperCase match { 80 | case "CONNECT" => HttpMethods.CONNECT 81 | case "DELETE" => HttpMethods.DELETE 82 | case "GET" => HttpMethods.GET 83 | case "HEAD" => HttpMethods.HEAD 84 | case "OPTIONS" => HttpMethods.OPTIONS 85 | case "PATCH" => HttpMethods.PATCH 86 | case "POST" => HttpMethods.POST 87 | case "PUT" => HttpMethods.PUT 88 | case "TRACE" => HttpMethods.TRACE 89 | case other => HttpMethod.custom(other) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scala Servers for Lambda 2 | Scala Servers for Lambda allows you to run existing Scala servers over API Gateway and AWS Lambda. 3 | 4 | Benefits: 5 | * Define logic once, and use it in both a server and serverless environment. 6 | * Simpler testing, as the logic has been isolated from Lambda completely. 7 | * No need to deal with Lambda's API directly, which aren't easy to use from Scala. 8 | * All code is deployed to single Lambda function, meaning our functions will be kept warm more often. 9 | 10 | ## Supported Servers 11 | 12 | * [http4s](http://http4s.org) - version 0.20 13 | * [akka-http](https://doc.akka.io/docs/akka-http/current) - version 10.1.1 14 | 15 | ## Dependencies 16 | 17 | Having a large JAR can increase cold start times, so dependencies have been kept to a minimum. All servers depend on [circe](https://circe.github.io/circe/). Additionally: 18 | 19 | * http4s depends on `http4s-core`. 20 | * akka-http depends on `akka-http` and `akka-stream`. 21 | 22 | Neither of these depend on the AWS SDK at all, which substantially reduces the size. 23 | 24 | ## Getting Started 25 | 26 | More thorough examples can be found in the examples directory. 27 | 28 | ### http4s 29 | 30 | First, add the dependency: 31 | 32 | ```scala 33 | libraryDependencies += "io.github.howardjohn" %% "http4s-lambda" % "0.3.1" 34 | ``` 35 | 36 | Next, we define a simple `HttpService`. Then, we simply need to define a new class for Lambda. 37 | 38 | ```scala 39 | object Route { 40 | // Set up the route 41 | val service: HttpService[IO] = HttpService[IO] { 42 | case GET -> Root / "hello" / name => Ok(s"Hello, $name!") 43 | } 44 | 45 | // Define the entry point for Lambda 46 | class EntryPoint extends Http4sLambdaHandler(service) 47 | } 48 | ``` 49 | 50 | Thats it! Make sure any dependencies are initialized in the Route object so they are computed only once. 51 | 52 | 53 | ### akka-http 54 | 55 | First, add the dependency: 56 | 57 | ```scala 58 | libraryDependencies += "io.github.howardjohn" %% "akka-http-lambda" % "0.3.1" 59 | ``` 60 | 61 | Next, we define a simple `Route`. Then, we simply need to define a new class for Lambda. 62 | 63 | ```scala 64 | object Route { 65 | // Set up the route 66 | val route: Route = 67 | path("hello" / Segment) { name: String => 68 | get { 69 | complete(s"Hello, $name!") 70 | } 71 | } 72 | 73 | // Set up dependencies 74 | implicit val system: ActorSystem = ActorSystem("example") 75 | implicit val materializer: ActorMaterializer = ActorMaterializer() 76 | implicit val ec = scala.concurrent.ExecutionContext.Implicits.global 77 | 78 | // Define the entry point for Lambda 79 | class EntryPoint extends AkkaHttpLambdaHandler(route) 80 | } 81 | ``` 82 | 83 | Thats it! Make sure any dependencies are initialized in the Route object so they are computed only once. 84 | 85 | ## Deploying to AWS 86 | 87 | To deploy to Lambda, we need to create a jar with all of our dependencies. The easiest way to do this is using [sbt-assembly](https://github.com/sbt/sbt-assembly). 88 | 89 | Once we have the jar, all we need to do is upload it to Lambda. The preferred way to do this is using the [serverless](https://github.com/serverless/serverless) framework which greatly simplifies this (and is what is used in the examples), but there is no issues with doing it manually. 90 | 91 | When deploying to Lambda, the handler should be specified as `.Route$EntryPoint::handle` (if you followed the example above). 92 | 93 | Finally, an API can be created in API Gateway. [Lambda Proxy integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) must be enabled. 94 | 95 | ## Versions 96 | 97 | | Version | http4s Version | akka-http Version | 98 | |--------------|----------------|-------------------| 99 | | 0.4 | 0.20 | 10.1 | 100 | | 0.3.1 | 0.18 | 10.1 | 101 | -------------------------------------------------------------------------------- /tests/src/main/scala/io/github/howardjohn/lambda/LambdaHandlerBehavior.scala: -------------------------------------------------------------------------------- 1 | package io.github.howardjohn.lambda 2 | 3 | import java.io.{ByteArrayInputStream, ByteArrayOutputStream} 4 | 5 | import io.circe.generic.auto._ 6 | import io.circe.parser.decode 7 | import io.circe.syntax._ 8 | import io.github.howardjohn.lambda.ProxyEncoding.{ProxyRequest, ProxyResponse} 9 | import org.scalatest.Assertions._ 10 | import org.scalatest.{FeatureSpec, GivenWhenThen} 11 | 12 | /** 13 | * Defines the behavior a LambdaHandler must implement. They should be tested against a set of the following routes: 14 | * GET /hello => "Hello World!" 15 | * GET /hello?times=3 => "Hello World! Hello World! Hello World!" 16 | * GET /long => takes a second to complete 17 | * POST /post with body => responds with the body 18 | * POST /json with json body { greeting: "Hello" } => responds with json { greeting: "Goodbye" } 19 | * GET /exception => throws a RouteException 20 | * GET /error => responds with a 500 error code 21 | * GET /header with header InputHeader => responds with a new header, 22 | * OutputHeader: outputHeaderValue and the value of InputHeader as the body 23 | */ 24 | trait LambdaHandlerBehavior { this: FeatureSpec with GivenWhenThen => 25 | import LambdaHandlerBehavior._ 26 | 27 | def behavior(handler: LambdaHandler) { 28 | scenario("A simple get request is made") { 29 | Given("a GET request to /hello") 30 | val response = runRequest("/hello")(handler) 31 | 32 | Then("the status code should be 200") 33 | assert(response.statusCode === 200) 34 | 35 | And("the body should be Hello World!") 36 | assert(response.body === "Hello World!") 37 | } 38 | 39 | scenario("Requesting an endpoint that doesn't exist") { 40 | Given("a GET request to /bad") 41 | val response = runRequest("/bad")(handler) 42 | 43 | Then("the status code should be 404") 44 | assert(response.statusCode === 404) 45 | } 46 | 47 | scenario("Including query parameters in a call") { 48 | Given("a GET request to /hello?times=3") 49 | val response = runRequest("/hello", query = Some(Map("times" -> "3")))(handler) 50 | 51 | Then("the status code should be 200") 52 | assert(response.statusCode === 200) 53 | 54 | And("the body should be Hello World! Hello World! Hello World!") 55 | assert(response.body === "Hello World! Hello World! Hello World!") 56 | } 57 | 58 | scenario("A request that takes a long time to respond") { 59 | Given("a GET request to /long") 60 | val response = runRequest("/long")(handler) 61 | 62 | Then("the status code should be 200") 63 | assert(response.statusCode === 200) 64 | } 65 | 66 | scenario("A POST call is made with a body") { 67 | Given("a POST request to /post") 68 | val response = runRequest("/post", body = Some("body"), httpMethod = "POST")(handler) 69 | 70 | Then("the status code should be 200") 71 | assert(response.statusCode === 200) 72 | 73 | And("the body should be body") 74 | assert(response.body === "body") 75 | } 76 | 77 | scenario("A POST call is made with a json body") { 78 | Given("a POST request with json to /json") 79 | val response = runRequest( 80 | "/json", 81 | body = Some(jsonBodyInput), 82 | httpMethod = "POST", 83 | headers = Some(Map("Content-Type" -> "application/json")) 84 | )(handler) 85 | 86 | Then("the status code should be 200") 87 | assert(response.statusCode === 200) 88 | 89 | And("the body should be body") 90 | assert(response.body === jsonReturn.asJson.noSpaces) 91 | } 92 | 93 | scenario("A request causes an exception") { 94 | Given("a GET request to /exception") 95 | val response = runRequest("/exception")(handler) 96 | Then("the status code should be 500") 97 | assert(response.statusCode === 500) 98 | } 99 | 100 | scenario("A request returns an error response") { 101 | Given("a GET request to /error") 102 | val response = runRequest("/error")(handler) 103 | 104 | Then("the status code should be 500") 105 | assert(response.statusCode === 500) 106 | } 107 | 108 | scenario("Request and responding with headers") { 109 | Given("a GET request to /header") 110 | val response = runRequest("/header", headers = Some(Map(inputHeader -> inputHeaderValue)))(handler) 111 | 112 | Then("the status code should be 200") 113 | assert(response.statusCode === 200) 114 | 115 | And("the body should be inputHeaderValue") 116 | assert(response.body === inputHeaderValue) 117 | 118 | And("the headers should include outputHeader") 119 | assert(response.headers.get(outputHeader) === Some(outputHeaderValue)) 120 | } 121 | } 122 | } 123 | 124 | object LambdaHandlerBehavior { 125 | case class RouteException() extends RuntimeException("There was an exception in the route") 126 | val outputHeader = "OutputHeader" 127 | val outputHeaderValue = "outputHeaderValue" 128 | val inputHeader = "InputHeader" 129 | val inputHeaderValue = "inputHeaderValue" 130 | 131 | case class JsonBody(greeting: String) 132 | val jsonBodyInput = JsonBody("Hello").asJson.noSpaces 133 | val jsonReturn = JsonBody("Goodbye") 134 | 135 | private def runRequest( 136 | path: String, 137 | httpMethod: String = "GET", 138 | headers: Option[Map[String, String]] = None, 139 | body: Option[String] = None, 140 | query: Option[Map[String, String]] = None 141 | )(handler: LambdaHandler): ProxyResponse = { 142 | val request = ProxyRequest( 143 | httpMethod, 144 | path, 145 | headers, 146 | body, 147 | query 148 | ).asJson.noSpaces 149 | val input = new ByteArrayInputStream(request.getBytes("UTF-8")) 150 | val output = new ByteArrayOutputStream() 151 | handler.handle(input, output) 152 | decode[ProxyResponse](new String(output.toByteArray)) match { 153 | case Left(err) => fail(err) 154 | case Right(resp) => resp 155 | } 156 | } 157 | } 158 | --------------------------------------------------------------------------------