├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .jvmopts ├── .scalafix.conf ├── .scalafmt.conf ├── LICENSE.txt ├── README.md ├── build.sbt ├── examples ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── feral │ │ └── examples │ │ └── Http4sGoogleCloud.scala ├── jvm │ └── src │ │ └── main │ │ └── scala │ │ └── feral │ │ └── examples │ │ └── Http4sGoogleCloud.scala └── shared │ └── src │ └── main │ └── scala │ └── feral │ └── examples │ ├── Http4sLambda.scala │ └── KinesisLambda.scala ├── google-cloud-http4s ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── feral │ │ └── google-cloud │ │ └── http4s │ │ └── IOCloudHttpFunction.scala └── jvm │ └── src │ ├── main │ └── scala │ │ └── feral │ │ └── google-cloud │ │ └── http4s │ │ └── IOCloudHttpFunction.scala │ └── test │ └── scala │ └── feral │ └── google-cloud │ └── http4s │ └── IOCloudHttpFunctionSuite.scala ├── lambda-cloudformation-custom-resource └── src │ ├── main │ └── scala │ │ └── feral │ │ └── lambda │ │ └── cloudformation │ │ ├── CloudFormationCustomResource.scala │ │ └── package.scala │ └── test │ └── scala │ └── feral │ └── lambda │ └── cloudformation │ ├── CloudFormationCustomResourceArbitraries.scala │ └── ResponseSerializationSuite.scala ├── lambda-http4s └── src │ ├── main │ └── scala │ │ └── feral │ │ └── lambda │ │ └── http4s │ │ ├── ApiGatewayProxyHandler.scala │ │ └── ApiGatewayProxyHandlerV2.scala │ └── test │ └── scala │ └── feral │ └── lambda │ └── http4s │ ├── ApiGatewayProxyHandlerSuite.scala │ └── ApiGatewayProxyHandlerV2Suite.scala ├── lambda ├── js │ └── src │ │ ├── main │ │ └── scala │ │ │ └── feral │ │ │ └── lambda │ │ │ ├── ContextPlatform.scala │ │ │ ├── IOLambdaPlatform.scala │ │ │ └── facade │ │ │ └── Context.scala │ │ └── test │ │ └── scala │ │ └── feral │ │ └── lambda │ │ └── IOLambdaJsSuite.scala ├── jvm │ └── src │ │ ├── main │ │ └── scala │ │ │ └── feral │ │ │ └── lambda │ │ │ ├── ContextPlatform.scala │ │ │ └── IOLambdaPlatform.scala │ │ └── test │ │ └── scala │ │ └── feral │ │ └── lambda │ │ └── IOLambdaJvmSuite.scala └── shared │ └── src │ ├── main │ ├── scala-2 │ │ └── feral │ │ │ └── lambda │ │ │ └── package.scala │ ├── scala-3 │ │ └── feral │ │ │ └── lambda │ │ │ ├── INothing.scala │ │ │ └── invocations.scala │ └── scala │ │ └── feral │ │ └── lambda │ │ ├── AwsTags.scala │ │ ├── Context.scala │ │ ├── IOLambda.scala │ │ ├── Invocation.scala │ │ ├── KernelSource.scala │ │ ├── TracedHandler.scala │ │ └── events │ │ ├── ApiGatewayProxyEvent.scala │ │ ├── ApiGatewayProxyEventV2.scala │ │ ├── ApiGatewayProxyResult.scala │ │ ├── ApiGatewayProxyResultV2.scala │ │ ├── ApiGatewayV2WebSocketEvent.scala │ │ ├── DynamoDbStreamEvent.scala │ │ ├── KafkaEvent.scala │ │ ├── KinesisStreamEvent.scala │ │ ├── S3BatchEvent.scala │ │ ├── S3BatchResult.scala │ │ ├── S3Event.scala │ │ ├── SnsEvent.scala │ │ ├── SqsEvent.scala │ │ ├── codecs.scala │ │ └── package.scala │ └── test │ └── scala │ └── feral │ └── lambda │ ├── TracedHandlerSuite.scala │ └── events │ ├── ApiGatewayProxyEventSuite.scala │ ├── ApiGatewayProxyEventV2Suite.scala │ ├── ApiGatewayV2WebSocketEventSuite.scala │ ├── DynamoDbStreamEventSuite.scala │ ├── InstantDecoderSuite.scala │ ├── KafkaEventSuite.scala │ ├── KinesisStreamEventSuite.scala │ ├── S3BatchEventSuite.scala │ ├── S3EventSuite.scala │ ├── SnsEventSuite.scala │ └── SqsEventSuite.scala ├── project ├── build.properties └── plugins.sbt ├── sbt-lambda └── src │ ├── main │ └── scala │ │ └── feral │ │ └── lambda │ │ └── sbt │ │ └── LambdaJSPlugin.scala │ └── sbt-test │ └── lambda-js-plugin │ └── iolambda-simple │ ├── build.sbt │ ├── project │ ├── build.properties │ └── plugins.sbt │ ├── src │ └── main │ │ └── scala │ │ └── mySimpleHandler.scala │ ├── test │ └── test-export.js └── scalafix ├── input └── src │ └── main │ └── scala │ └── example │ └── V0_3_0Rewrites.scala ├── output └── src │ └── main │ └── scala │ └── example │ └── V0_3_0Rewrites.scala ├── rules └── src │ └── main │ ├── resources │ └── META-INF │ │ └── services │ │ └── scalafix.v1.Rule │ └── scala │ └── feral │ └── scalafix │ └── V0_3_0Rewrites.scala └── tests └── src └── test └── scala └── feral └── scalafix └── RuleSuite.scala /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | .sbtopts 4 | 5 | # vim 6 | *.sw? 7 | 8 | # intellij 9 | .idea/ 10 | 11 | # Ignore [ce]tags files 12 | tags 13 | 14 | # Metals 15 | .metals/ 16 | .bsp/ 17 | .bloop/ 18 | metals.sbt 19 | .vscode 20 | 21 | # npm 22 | node_modules/ 23 | -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -Xms1G 2 | -Xmx4G 3 | -XX:+UseG1GC 4 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [OrganizeImports] 2 | OrganizeImports.removeUnused = false 3 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.8.3 2 | 3 | runner.dialect = Scala213Source3 4 | fileOverride { 5 | "glob:**/scala-3/**/*.scala" { 6 | runner.dialect = scala3 7 | } 8 | "glob:**/scala-2.13/**/*.scala" { 9 | runner.dialect = scala213 10 | } 11 | } 12 | 13 | maxColumn = 96 14 | 15 | includeCurlyBraceInSelectChains = true 16 | includeNoParensInSelectChains = true 17 | 18 | optIn { 19 | breakChainOnFirstMethodDot = false 20 | forceBlankLineBeforeDocstring = true 21 | } 22 | 23 | binPack { 24 | literalArgumentLists = true 25 | parentConstructors = Never 26 | } 27 | 28 | danglingParentheses { 29 | defnSite = false 30 | callSite = false 31 | ctrlSite = false 32 | 33 | exclude = [] 34 | } 35 | 36 | newlines { 37 | beforeCurlyLambdaParams = multilineWithCaseOnly 38 | afterCurlyLambda = squash 39 | implicitParamListModifierPrefer = before 40 | sometimesBeforeColonInMethodReturnType = true 41 | } 42 | 43 | align.preset = none 44 | align.stripMargin = true 45 | 46 | assumeStandardLibraryStripMargin = true 47 | 48 | docstrings { 49 | style = Asterisk 50 | oneline = unfold 51 | } 52 | 53 | project.git = true 54 | 55 | trailingCommas = never 56 | 57 | rewrite { 58 | // RedundantBraces honestly just doesn't work, otherwise I'd love to use it 59 | rules = [PreferCurlyFors, RedundantParens, SortImports] 60 | 61 | redundantBraces { 62 | maxLines = 1 63 | stringInterpolation = true 64 | } 65 | } 66 | 67 | rewriteTokens { 68 | "⇒": "=>" 69 | "→": "->" 70 | "←": "<-" 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feral [![feral-core Scala version support](https://index.scala-lang.org/typelevel/feral/feral-lambda/latest.svg?color=red)](https://index.scala-lang.org/typelevel/feral/feral-lambda) [![javadoc](https://javadoc.io/badge2/org.typelevel/feral-docs_2.13/javadoc.svg?color=red)](https://javadoc.io/doc/org.typelevel/feral-docs_2.13) [![Discord](https://img.shields.io/discord/632277896739946517.svg?label=&logo=discord&logoColor=ffffff&color=404244&labelColor=6A7EC2)](https://discord.gg/AJASeCq8gN) 2 | 3 | feral is a framework for writing serverless functions in Scala with [Cats Effect](https://github.com/typelevel/cats-effect) and deploying them to the cloud, targeting both JVM and JavaScript runtimes. By providing an idiomatic, purely functional interface, feral is both composable—integrations with [natchez](https://github.com/tpolecat/natchez) and [http4s](https://github.com/http4s/http4s) are provided out-of-the-box—and also highly customizable. The initial focus has been on supporting [AWS Lambda](https://aws.amazon.com/lambda/) and will expand to other serverless providers. 4 | 5 | ## Getting started 6 | 7 | Feral is published for Scala 2.13 and 3.2+ with artifacts for both JVM and Scala.js 1.13+. 8 | 9 | ```scala 10 | // Scala.js setup 11 | addSbtPlugin("org.typelevel" %% "sbt-feral-lambda" % "0.2.2") // in plugins.sbt 12 | enablePlugins(LambdaJSPlugin) // in build.sbt 13 | 14 | // JVM setup 15 | libraryDependencies += "org.typelevel" %% "feral-lambda" % "0.2.2" 16 | 17 | // Optional, specialized integrations, available for both JS and JVM 18 | libraryDependencies += "org.typelevel" %%% "feral-lambda-http4s" % "0.2.2" 19 | libraryDependencies += "org.typelevel" %%% "feral-lambda-cloudformation-custom-resource" % "0.2.2" 20 | ``` 21 | 22 | Next, implement your Lambda. Please refer to the [examples](examples/src/main/scala/feral/examples) for a tutorial. 23 | 24 | There are several options to deploy your Lambda. For example you can use the [Lambda console](https://docs.aws.amazon.com/lambda/latest/dg/foundation-console.html), the [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html), or the [serverless framework](https://www.serverless.com/framework/docs/providers/aws/guide/deploying). 25 | 26 | To deploy a Scala.js Lambda, you will need to know the following: 27 | 1. The runtime for your Lambda is Node.js 18. 28 | 2. The handler for your Lambda is `index.yourLambdaName`. 29 | - `index` refers to the `index.js` file containing the JavaScript sources for your Lambda. 30 | - `yourLambdaName` is the name of the Scala `object` you created that extends from `IOLambda`. 31 | 3. Run `sbt npmPackage` to package your Lambda for deployment. Note that you can currently only have one Lambda per sbt (sub-)project. If you have multiple, you will need to select the one to deploy using `Compile / mainClass := Some("my.lambda.handler")`. 32 | 4. For the tooling of your choice, follow their instructions for deploying a Node.js Lambda using the contents of the `target/scala-2.13/npm-package/` directory. 33 | 34 | As the feral project develops, one of the goals is to provide an sbt plugin that simplifies and automates the deployment process. If this appeals to you, please contribute feature requests, ideas, and/or code! 35 | 36 | ## Why go feral? 37 | 38 | The premise that you can (and should!) write production-ready serverless functions in Scala targeting JavaScript may be a surprising one. This project—and the rapid maturity of the Typelevel.js ecosystem—is motivated by three ideas. 39 | 40 | 1. **JavaScript is the ideal compile target for serverless functions.** 41 | 42 | There are a lot of reasons for this, cold-start being one of them, but more generally it's important to remember what the JVM is and is not good at. In particular, the JVM excels at long-lived multithreaded applications which are relatively memory-heavy and rely on medium-lifespan heap allocations. So in other words, persistent microservices. 43 | 44 | Serverless functions are, by definition, not this. They are not persistent, they are (generally) single-threaded, and they need to start very quickly with minimal warming. They do often apply moderate-to-significant heap pressure, but this factor is more than outweighed by the others. 45 | 46 | V8 (the JavaScript engine in Node.js) is a very good runtime for these kinds of use-cases. Realistically, it may be the best-optimized runtime in existence for these requirements, similar to how the JVM is likely the best-optimized runtime in existence for the persistent microservices case. 47 | 48 | 2. **Scala.js and Cats Effect work together to provide powerful, well-defined semantics for writing JavaScript applications.** 49 | 50 | It hopefully should not take much convincing that Scala is a fantastic language to use, regardless of the ultimate compile target. But what might be unexpected by those new to Scala.js is how well it preserves Scala's JVM semantics in JavaScript. Save a few [edge-cases](https://www.scala-js.org/doc/semantics.html), by and large Scala programs behave the same on JS as they do on the JVM. 51 | 52 | Cats Effect takes this a step further by establishing [semantics for _asynchronous_ programming](https://typelevel.org/cats-effect/docs/typeclasses) (aka laws) and guaranteeing them across the JVM and JS. In fact, the initial testing of these semantics on Scala.js revealed a [fairness issue](https://github.com/scala-js/scala-js/issues/4129) that culminated in [the deprecation](http://www.scala-js.org/news/2021/12/10/announcing-scalajs-1.8.0/#new-compiler-warnings-with-broad-applicability) of the default global `ExecutionContext` in Scala.js. As a replacement, the [`MacrotaskExecutor` project](https://github.com/scala-js/scala-js-macrotask-executor) was extracted from Cats Effect and is now the official recommendation for all Scala.js applications. Cats Effect `IO` is specifically optimized to take advantage of the `MacrotaskExecutor`'s fairness properties while maximizing throughput and performance. 53 | 54 | `IO` also has features to enrich the observability and debuggability of your JavaScript applications during development. [Tracing and enhanced exceptions](https://typelevel.org/cats-effect/docs/tracing) capture the execution graph of a process in your program, even across asynchronous boundaries, while [fiber dumps](https://github.com/typelevel/cats-effect/releases/tag/v3.3.0) enable you to introspect the traces of _all_ the concurrent processes in your program at any given time. 55 | 56 | 3. **Your favorite Typelevel libraries are already designed for Scala.js.** 57 | 58 | Thanks to the platform-independent semantics, software built using abstractions from Cats Effect and other Typelevel libraries can often be easily cross-compiled for Scala.js. One spectacular example of this is [skunk](https://github.com/tpolecat/skunk), a data access library for Postgres that was never intended to target JavaScript. However, due to its whole-hearted adoption of purely functional asynchronous programming, today it also runs on Node.js with _virtually no changes to its source code_. 59 | 60 | In practice, this means you can directly transfer your knowledge and experience writing Scala for the JVM to writing Scala.js and in many cases share code with your JVM applications. The following libraries offer _identical_ APIs across the JVM and JS platforms: 61 | * [Cats](https://github.com/typelevel/cats) and [Cats Effect](https://github.com/typelevel/cats-effect) for purely functional, asynchronous programming 62 | * [fs2](https://github.com/typelevel/fs2) and [fs2-io](https://github.com/typelevel/fs2), with support for TCP, UDP, and TLS 63 | * [http4s](https://github.com/http4s/http4s), including DSLs, server/client middlewares, and ember 64 | * [natchez](https://github.com/tpolecat/natchez) and [natchez-http4s](https://github.com/tpolecat/natchez-http4s) for tracing 65 | * [skunk](https://github.com/tpolecat/skunk) for Postgres/Redshift and [rediculous](https://github.com/davenverse/rediculous) for Redis 66 | * [circe](https://github.com/circe/circe), [scodec](https://github.com/scodec/scodec) and [scodec-bits](https://github.com/scodec/scodec-bits) for encoders/decoders 67 | * [smithy4s](https://disneystreaming.github.io/smithy4s/docs/protocols/aws/aws/) for AWS clients 68 | * and more ... 69 | -------------------------------------------------------------------------------- /examples/js/src/main/scala/feral/examples/Http4sGoogleCloud.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.examples 18 | 19 | import cats.effect._ 20 | import feral.googlecloud._ 21 | import org.http4s._ 22 | import org.http4s.dsl.io._ 23 | 24 | object http4sGoogleCloudHandler extends IOCloudHttpFunction { 25 | def handler = { 26 | val app = HttpRoutes 27 | .of[IO] { 28 | case GET -> Root / "hello" / name => 29 | Ok(s"Hello, $name.") 30 | } 31 | .orNotFound 32 | 33 | Resource.pure(app) 34 | 35 | /*Resource.pure(HttpApp.pure(Response[IO](Status.Ok)))*/ 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /examples/jvm/src/main/scala/feral/examples/Http4sGoogleCloud.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.examples 18 | 19 | import cats.effect._ 20 | import feral.googlecloud._ 21 | import org.http4s._ 22 | import org.http4s.dsl.io._ 23 | 24 | class http4sGoogleCloudHandler extends IOCloudHttpFunction { 25 | def handler = { 26 | val app = HttpRoutes 27 | .of[IO] { 28 | case GET -> Root / "hello" / name => 29 | Ok(s"Hello, $name.") 30 | } 31 | .orNotFound 32 | 33 | Resource.pure(app) 34 | 35 | /*Resource.pure(HttpApp.pure(Response[IO](Status.Ok)))*/ 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/feral/examples/Http4sLambda.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.examples 18 | 19 | import cats.effect._ 20 | import cats.effect.std.Random 21 | import feral.lambda._ 22 | import feral.lambda.events._ 23 | import feral.lambda.http4s._ 24 | import natchez.Trace 25 | import natchez.http4s.NatchezMiddleware 26 | import natchez.xray.XRay 27 | import org.http4s.HttpApp 28 | import org.http4s.HttpRoutes 29 | import org.http4s.client.Client 30 | import org.http4s.dsl.Http4sDsl 31 | import org.http4s.ember.client.EmberClientBuilder 32 | import org.http4s.syntax.all._ 33 | 34 | /** 35 | * For a gentle introduction, please look at the `KinesisLambda` first which uses 36 | * `IOLambda.Simple`. 37 | * 38 | * The `IOLambda` uses a slightly more complicated encoding by introducing an effect 39 | * `Invocation[F]` which provides access to the event and context in `F`. This allows you to 40 | * compose your handler as a stack of "middlewares", making it easy to e.g. add tracing to your 41 | * Lambda. 42 | */ 43 | object http4sHandler 44 | extends IOLambda[ApiGatewayProxyEventV2, ApiGatewayProxyStructuredResultV2] { 45 | 46 | /** 47 | * Actually, this is a `Resource` that builds your handler. The handler is acquired exactly 48 | * once when your Lambda starts and is permanently installed to process all incoming events. 49 | * 50 | * The handler itself is a program expressed as `IO[Option[Result]]`, which is run every time 51 | * that your Lambda is triggered. This may seem counter-intuitive at first: where does the 52 | * event come from? Because accessing the event via `Invocation` is now also an effect in 53 | * `IO`, it becomes a step in your program. 54 | */ 55 | def handler = for { 56 | entrypoint <- Resource 57 | .eval(Random.scalaUtilRandom[IO]) 58 | .flatMap(implicit r => XRay.entryPoint[IO]()) 59 | client <- EmberClientBuilder.default[IO].build 60 | } yield { implicit inv => // the Invocation provides access to the event and context 61 | 62 | // a middleware to add tracing to any handler 63 | // it extracts the kernel from the event and adds tags derived from the context 64 | TracedHandler(entrypoint) { implicit trace => 65 | val tracedClient = NatchezMiddleware.client(client) 66 | 67 | // a "middleware" that converts an HttpApp into a ApiGatewayProxyHandler 68 | ApiGatewayProxyHandlerV2(myApp[IO](tracedClient)) 69 | } 70 | } 71 | 72 | /** 73 | * Nothing special about this method, including its existence, just an example :) 74 | */ 75 | def myApp[F[_]: Concurrent: Trace](client: Client[F]): HttpApp[F] = { 76 | implicit val dsl = Http4sDsl[F] 77 | import dsl._ 78 | 79 | val routes = HttpRoutes.of[F] { 80 | case GET -> Root / "foo" => Ok("bar") 81 | case GET -> Root / "joke" => Ok(client.expect[String](uri"icanhazdadjoke.com")) 82 | } 83 | 84 | NatchezMiddleware.server(routes).orNotFound 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /examples/shared/src/main/scala/feral/examples/KinesisLambda.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.examples 18 | 19 | import cats.effect._ 20 | import cats.effect.std.Random 21 | import feral.lambda._ 22 | import feral.lambda.events.SqsEvent 23 | import natchez.Trace 24 | import natchez.xray.XRay 25 | import skunk.Session 26 | 27 | /** 28 | * On Scala.js, implement your Lambda as an `object`. This will be the name your JavaScript 29 | * function is exported as. On JVM, implement your Lambda as a `class`. 30 | * 31 | * Every Lambda is triggered by an `Event` for which there must be a circe `Decoder[Event]`. It 32 | * should then return `Some[Result]` for which there must be a circe `Encoder[Result]`. If your 33 | * Lambda has no result (as is often the case), use `INothing` and return `None` in the handler. 34 | * 35 | * Models for events/results are provided in the `feral.lambda.events` package. There are many 36 | * more to implement! Please consider contributing to 37 | * [[https://github.com/typelevel/feral/issues/48 #48]]. 38 | * 39 | * For a more advanced example, see the `Http4sLambda` next. 40 | */ 41 | object sqsHandler extends IOLambda.Simple[SqsEvent, INothing] { 42 | 43 | /** 44 | * Optional initialization section. This is a resource that will be acquired exactly once when 45 | * your lambda starts and re-used for each event that it processes. 46 | * 47 | * If you do not need to perform any such initialization, you may omit this section. 48 | */ 49 | type Init = Session[IO] // a skunk session 50 | override def init = 51 | Resource.eval(Random.scalaUtilRandom[IO]).flatMap { implicit random => 52 | XRay.entryPoint[IO]().flatMap { entrypoint => 53 | entrypoint.root("root").evalMap(Trace.ioTrace).flatMap { implicit trace => 54 | Session.single[IO](host = "host", user = "user", database = "db") 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * This is where you implement the logic of your handler. 61 | * 62 | * @param event 63 | * that triggered your lambda 64 | * @param context 65 | * provides information about the invocation, function, and execution environment 66 | * @param init 67 | * in this example, the skunk session we setup above 68 | */ 69 | def apply(event: SqsEvent, context: Context[IO], init: Init) = 70 | IO.println(s"Received event with ${event.records.size} records").as(None) 71 | 72 | } 73 | -------------------------------------------------------------------------------- /google-cloud-http4s/js/src/main/scala/feral/google-cloud/http4s/IOCloudHttpFunction.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.googlecloud 18 | 19 | import cats.effect.IO 20 | import cats.effect.kernel.Resource 21 | import cats.effect.std.Dispatcher 22 | import cats.effect.unsafe.IORuntime 23 | import cats.syntax.all._ 24 | import org.http4s.HttpApp 25 | import org.http4s.nodejs.IncomingMessage 26 | import org.http4s.nodejs.ServerResponse 27 | 28 | import scala.scalajs.js 29 | import scala.scalajs.js.annotation._ 30 | 31 | object IOCloudHttpFunction { 32 | @js.native 33 | @JSImport("@google-cloud/functions-framework", "http") 34 | private[googlecloud] def http( 35 | functionName: String, 36 | handler: js.Function2[IncomingMessage, ServerResponse, Unit]): Unit = js.native 37 | } 38 | 39 | abstract class IOCloudHttpFunction { 40 | 41 | final def main(args: Array[String]): Unit = 42 | IOCloudHttpFunction.http(functionName, handlerFn) 43 | 44 | protected def functionName: String = getClass.getSimpleName.init 45 | 46 | protected def runtime: IORuntime = IORuntime.global 47 | 48 | def handler: Resource[IO, HttpApp[IO]] 49 | 50 | private[googlecloud] lazy val handlerFn 51 | : js.Function2[IncomingMessage, ServerResponse, Unit] = { 52 | val dispatcherHandle = { 53 | Dispatcher 54 | .parallel[IO](await = false) 55 | .product(handler) 56 | .allocated 57 | .map(_._1) // drop unused finalizer 58 | .unsafeToPromise()(runtime) 59 | } 60 | 61 | (request: IncomingMessage, response: ServerResponse) => 62 | val _ = dispatcherHandle.`then`[Unit] { 63 | case (dispatcher, handle) => 64 | dispatcher.unsafeRunAndForget( 65 | request.toRequest[IO].flatMap(handle(_)).flatMap(response.writeResponse[IO]) 66 | ) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /google-cloud-http4s/jvm/src/main/scala/feral/google-cloud/http4s/IOCloudHttpFunction.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.googlecloud 18 | 19 | import cats.effect.Async 20 | import cats.effect.IO 21 | import cats.effect.Resource 22 | import cats.effect.std.Dispatcher 23 | import cats.effect.syntax.all._ 24 | import cats.effect.unsafe.IORuntime 25 | import cats.syntax.all._ 26 | import com.google.cloud.functions.HttpFunction 27 | import com.google.cloud.functions.HttpRequest 28 | import com.google.cloud.functions.HttpResponse 29 | import org.http4s.Header 30 | import org.http4s.Headers 31 | import org.http4s.HttpApp 32 | import org.http4s.Method 33 | import org.http4s.Request 34 | import org.http4s.Response 35 | import org.http4s.Uri 36 | import org.typelevel.ci.CIString 37 | 38 | import scala.util.control.NonFatal 39 | 40 | object IOCloudHttpFunction { 41 | private[googlecloud] def fromHttpRequest(request: HttpRequest): IO[Request[IO]] = for { 42 | method <- Method.fromString(request.getMethod()).liftTo[IO] 43 | uri <- Uri.fromString(request.getUri()).liftTo[IO] 44 | headers <- IO { 45 | val builder = List.newBuilder[Header.Raw] 46 | request.getHeaders().forEach { 47 | case (name, values) => 48 | values.forEach { value => builder += Header.Raw(CIString(name), value) } 49 | } 50 | Headers(builder.result()) 51 | } 52 | body = fs2.io.readInputStream(IO(request.getInputStream()), 4096) 53 | } yield Request( 54 | method, 55 | uri, 56 | headers = headers, 57 | body = body 58 | ) 59 | 60 | private[googlecloud] def writeResponse( 61 | http4sResponse: Response[IO], 62 | googleResponse: HttpResponse): IO[Unit] = 63 | for { 64 | _ <- IO { 65 | googleResponse.setStatusCode(http4sResponse.status.code, http4sResponse.status.reason) 66 | } 67 | 68 | _ <- IO { 69 | http4sResponse.headers.foreach { header => 70 | { 71 | googleResponse.appendHeader(header.name.toString, header.value) 72 | } 73 | } 74 | } 75 | 76 | _ <- http4sResponse 77 | .body 78 | .through(fs2.io.writeOutputStream(IO(googleResponse.getOutputStream()))) 79 | .compile 80 | .drain 81 | 82 | } yield () 83 | } 84 | 85 | abstract class IOCloudHttpFunction extends HttpFunction { 86 | 87 | protected def runtime: IORuntime = IORuntime.global 88 | 89 | def handler: Resource[IO, HttpApp[IO]] 90 | 91 | private[this] val (dispatcher, handle) = { 92 | val handler = { 93 | val h = 94 | try this.handler 95 | catch { case ex if NonFatal(ex) => null } 96 | 97 | if (h ne null) { 98 | h.map(IO.pure(_)) 99 | } else { 100 | val functionName = getClass().getSimpleName() 101 | val msg = 102 | s"""|There was an error initializing `$functionName` during startup. 103 | |Falling back to initialize-during-first-invocation strategy. 104 | |To fix, try replacing any `val`s in `$functionName` with `def`s.""".stripMargin 105 | System.err.println(msg) 106 | 107 | Async[Resource[IO, *]].defer(this.handler).memoize.map(_.allocated.map(_._1)) 108 | } 109 | } 110 | 111 | Dispatcher 112 | .parallel[IO](await = false) 113 | .product(handler) 114 | .allocated 115 | .map(_._1) // drop unused finalizer 116 | .unsafeRunSync()(runtime) 117 | } 118 | 119 | final def service(request: HttpRequest, response: HttpResponse): Unit = { 120 | 121 | dispatcher.unsafeRunSync( 122 | IOCloudHttpFunction.fromHttpRequest(request).flatMap { req => 123 | handle.flatMap(_(req)).flatMap { res => 124 | IOCloudHttpFunction.writeResponse(res, response) 125 | } 126 | } 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /google-cloud-http4s/jvm/src/test/scala/feral/google-cloud/http4s/IOCloudHttpFunctionSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.googlecloud 18 | 19 | import cats.effect.IO 20 | import com.google.cloud.functions.HttpRequest 21 | import com.google.cloud.functions.HttpResponse 22 | import feral.googlecloud.IOCloudHttpFunction._ 23 | import munit.CatsEffectSuite 24 | import org.http4s.Headers 25 | import org.http4s.Method 26 | import org.http4s.Response 27 | import org.http4s.Uri 28 | import org.http4s.syntax.all._ 29 | import scodec.bits.ByteVector 30 | 31 | import java.io.BufferedReader 32 | import java.io.BufferedWriter 33 | import java.io.ByteArrayOutputStream 34 | import java.io.InputStream 35 | import java.io.OutputStream 36 | import java.{util => ju} 37 | 38 | class IOCloudHttpFunctionSuite extends CatsEffectSuite { 39 | 40 | class GoogleRequest( 41 | val method: Method, 42 | val uri: Uri, 43 | val headers: Headers, 44 | val body: ByteVector) 45 | extends HttpRequest { 46 | def getMethod(): String = method.name 47 | def getUri(): String = uri.renderString 48 | def getHeaders(): ju.Map[String, ju.List[String]] = { 49 | val juHeaders = new ju.LinkedHashMap[String, ju.List[String]] 50 | headers.foreach { header => 51 | juHeaders 52 | .computeIfAbsent(header.name.toString, _ => new ju.LinkedList[String]()) 53 | .add(header.value) 54 | () 55 | } 56 | juHeaders 57 | } 58 | def getInputStream(): InputStream = body.toInputStream 59 | def getContentType(): ju.Optional[String] = ??? 60 | def getContentLength(): Long = ??? 61 | def getCharacterEncoding(): ju.Optional[String] = ??? 62 | def getReader(): BufferedReader = ??? 63 | def getPath(): String = ??? 64 | def getQuery(): ju.Optional[String] = ??? 65 | def getQueryParameters(): ju.Map[String, ju.List[String]] = ??? 66 | def getParts(): ju.Map[String, HttpRequest.HttpPart] = ??? 67 | } 68 | 69 | class GoogleResponse extends HttpResponse { 70 | var statusCode: Option[(Int, String)] = None 71 | val headers = new ju.HashMap[String, ju.List[String]] 72 | val body = new ByteArrayOutputStream 73 | def setStatusCode(code: Int): Unit = ??? 74 | def setStatusCode(x: Int, y: String): Unit = statusCode = Some((x, y)) 75 | def appendHeader(header: String, value: String): Unit = { 76 | headers.computeIfAbsent(header, _ => new ju.LinkedList[String]()).add(value) 77 | () 78 | } 79 | def getOutputStream(): OutputStream = body 80 | def getHeaders(): ju.Map[String, ju.List[String]] = headers 81 | def getStatusCode(): (Int, String) = statusCode.get 82 | def getContentType(): ju.Optional[String] = ??? 83 | def getWriter(): BufferedWriter = ??? 84 | def setContentType(contentType: String): Unit = ??? 85 | } 86 | 87 | var http4sResponse = Response[IO]() 88 | 89 | test("decode request") { 90 | for { 91 | request <- fromHttpRequest( 92 | new GoogleRequest( 93 | Method.GET, 94 | uri"/default/nodejs-apig-function-1G3XMPLZXVXYI?", 95 | expectedHeaders, 96 | ByteVector.empty)) 97 | _ <- IO(assertEquals(request.method, Method.GET)) 98 | _ <- IO(assertEquals(request.uri, uri"/default/nodejs-apig-function-1G3XMPLZXVXYI?")) 99 | _ <- IO(assertEquals(request.headers, expectedHeaders)) 100 | _ <- request.body.compile.to(ByteVector).assertEquals(ByteVector.empty) 101 | } yield () 102 | } 103 | 104 | test("encode response") { 105 | for { 106 | gResponse <- IO(new GoogleResponse) 107 | _ <- writeResponse(http4sResponse, gResponse) 108 | _ <- IO(assertEquals(gResponse.getStatusCode(), (200, "OK"))) 109 | _ <- IO(assertEquals(gResponse.getHeaders(), new ju.HashMap[String, ju.List[String]]())) 110 | _ <- IO(assertEquals(ByteVector(gResponse.body.toByteArray()), ByteVector.empty)) 111 | } yield () 112 | } 113 | 114 | def expectedHeaders = Headers( 115 | "content-length" -> "0", 116 | "accept-language" -> "en-US,en;q=0.9", 117 | "sec-fetch-dest" -> "document", 118 | "sec-fetch-user" -> "?1", 119 | "x-amzn-trace-id" -> "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e", 120 | "host" -> "r3pmxmplak.execute-api.us-east-2.amazonaws.com", 121 | "sec-fetch-mode" -> "navigate", 122 | "accept-encoding" -> "gzip, deflate, br", 123 | "accept" -> 124 | "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", 125 | "sec-fetch-site" -> "cross-site", 126 | "x-forwarded-port" -> "443", 127 | "x-forwarded-proto" -> "https", 128 | "upgrade-insecure-requests" -> "1", 129 | "user-agent" -> 130 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36", 131 | "x-forwarded-for" -> "205.255.255.176", 132 | "cookie" -> "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2; regStatus=pre-register" 133 | ) 134 | 135 | } 136 | -------------------------------------------------------------------------------- /lambda-cloudformation-custom-resource/src/main/scala/feral/lambda/cloudformation/CloudFormationCustomResource.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package cloudformation 19 | 20 | import cats._ 21 | import cats.syntax.all._ 22 | import feral.lambda.cloudformation.CloudFormationRequestType._ 23 | import io.circe._ 24 | import io.circe.syntax._ 25 | import org.http4s.EntityEncoder 26 | import org.http4s.Method.PUT 27 | import org.http4s.circe._ 28 | import org.http4s.client.Client 29 | import org.http4s.client.dsl.Http4sClientDsl 30 | 31 | trait CloudFormationCustomResource[F[_], Input, Output] { 32 | def createResource(input: Input): F[HandlerResponse[Output]] 33 | def updateResource( 34 | input: Input, 35 | physicalResourceId: PhysicalResourceId): F[HandlerResponse[Output]] 36 | def deleteResource( 37 | input: Input, 38 | physicalResourceId: PhysicalResourceId): F[HandlerResponse[Output]] 39 | } 40 | 41 | object CloudFormationCustomResource { 42 | private[cloudformation] implicit def jsonEncoder[F[_]]: EntityEncoder[F, Json] = 43 | jsonEncoderWithPrinter(Printer.noSpaces.copy(dropNullValues = true)) 44 | 45 | def apply[F[_]: MonadThrow, Input, Output: Encoder]( 46 | client: Client[F], 47 | handler: CloudFormationCustomResource[F, Input, Output])( 48 | implicit 49 | inv: Invocation[F, CloudFormationCustomResourceRequest[Input]]): F[Option[INothing]] = { 50 | val http4sClientDsl = new Http4sClientDsl[F] {} 51 | import http4sClientDsl._ 52 | 53 | inv.event.flatMap { event => 54 | ((event.RequestType, event.PhysicalResourceId) match { 55 | case (CreateRequest, None) => handler.createResource(event.ResourceProperties) 56 | case (UpdateRequest, Some(id)) => handler.updateResource(event.ResourceProperties, id) 57 | case (DeleteRequest, Some(id)) => handler.deleteResource(event.ResourceProperties, id) 58 | case (other, _) => illegalRequestType(other.toString) 59 | }).attempt 60 | .map(_.fold(exceptionResponse(event)(_), successResponse(event)(_))) 61 | .flatMap { resp => client.successful(PUT(resp.asJson, event.ResponseURL)) } 62 | .as(None) 63 | } 64 | } 65 | 66 | private def illegalRequestType[F[_]: ApplicativeThrow, A](other: String): F[A] = 67 | (new IllegalArgumentException( 68 | s"unexpected CloudFormation request type `$other`"): Throwable).raiseError[F, A] 69 | 70 | private[cloudformation] def exceptionResponse(req: CloudFormationCustomResourceRequest[_])( 71 | ex: Throwable): CloudFormationCustomResourceResponse = 72 | CloudFormationCustomResourceResponse( 73 | Status = RequestResponseStatus.Failed, 74 | Reason = Option(ex.getMessage), 75 | PhysicalResourceId = req.PhysicalResourceId, 76 | StackId = req.StackId, 77 | RequestId = req.RequestId, 78 | LogicalResourceId = req.LogicalResourceId, 79 | Data = JsonObject( 80 | "StackTrace" -> Json.arr(stackTraceLines(ex).map(Json.fromString): _*)).asJson 81 | ) 82 | 83 | private[cloudformation] def successResponse[Output: Encoder]( 84 | req: CloudFormationCustomResourceRequest[_])( 85 | res: HandlerResponse[Output]): CloudFormationCustomResourceResponse = 86 | CloudFormationCustomResourceResponse( 87 | Status = RequestResponseStatus.Success, 88 | Reason = None, 89 | PhysicalResourceId = Option(res.physicalId), 90 | StackId = req.StackId, 91 | RequestId = req.RequestId, 92 | LogicalResourceId = req.LogicalResourceId, 93 | Data = res.data.asJson 94 | ) 95 | 96 | // TODO figure out how to include more of the stack trace, up to a total response size of 4096 bytes 97 | private def stackTraceLines(throwable: Throwable): List[String] = 98 | List(throwable.toString) 99 | 100 | } 101 | -------------------------------------------------------------------------------- /lambda-cloudformation-custom-resource/src/main/scala/feral/lambda/cloudformation/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.syntax.all._ 20 | import io.circe._ 21 | import io.circe.syntax._ 22 | import monix.newtypes._ 23 | import org.http4s.Uri 24 | import org.http4s.circe.CirceInstances 25 | 26 | package object cloudformation { 27 | type PhysicalResourceId = PhysicalResourceId.Type 28 | type StackId = StackId.Type 29 | type RequestId = RequestId.Type 30 | type LogicalResourceId = LogicalResourceId.Type 31 | type ResourceType = ResourceType.Type 32 | } 33 | 34 | package cloudformation { 35 | 36 | object PhysicalResourceId extends Newtype[String] { 37 | 38 | /** 39 | * Applies validation rules from 40 | * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html 41 | */ 42 | def apply(s: String): Option[Type] = 43 | Option.when(s.length <= 1024 && s.nonEmpty)(unsafeCoerce(s)) 44 | 45 | def unsafeApply(s: String): PhysicalResourceId = unsafeCoerce(s) 46 | 47 | def unapply[A](a: A)(implicit ev: A =:= Type): Some[String] = 48 | Some(value(ev(a))) 49 | 50 | implicit val PhysicalResourceIdDecoder: Decoder[PhysicalResourceId] = derive[Decoder] 51 | implicit val PhysicalResourceIdEncoder: Encoder[PhysicalResourceId] = derive[Encoder] 52 | } 53 | object StackId extends NewtypeWrapped[String] { 54 | implicit val StackIdDecoder: Decoder[StackId] = derive[Decoder] 55 | implicit val StackIdEncoder: Encoder[StackId] = derive[Encoder] 56 | } 57 | object RequestId extends NewtypeWrapped[String] { 58 | implicit val RequestIdDecoder: Decoder[RequestId] = derive[Decoder] 59 | implicit val RequestIdEncoder: Encoder[RequestId] = derive[Encoder] 60 | } 61 | object LogicalResourceId extends NewtypeWrapped[String] { 62 | implicit val LogicalResourceIdDecoder: Decoder[LogicalResourceId] = derive[Decoder] 63 | implicit val LogicalResourceIdEncoder: Encoder[LogicalResourceId] = derive[Encoder] 64 | } 65 | object ResourceType extends NewtypeWrapped[String] { 66 | implicit val ResourceTypeDecoder: Decoder[ResourceType] = derive[Decoder] 67 | implicit val ResourceTypeEncoder: Encoder[ResourceType] = derive[Encoder] 68 | } 69 | 70 | sealed trait CloudFormationRequestType 71 | object CloudFormationRequestType { 72 | case object CreateRequest extends CloudFormationRequestType 73 | case object UpdateRequest extends CloudFormationRequestType 74 | case object DeleteRequest extends CloudFormationRequestType 75 | final case class OtherRequestType(requestType: String) extends CloudFormationRequestType { 76 | override def toString: String = requestType 77 | } 78 | 79 | implicit val encoder: Encoder[CloudFormationRequestType] = { 80 | case CreateRequest => "Create".asJson 81 | case UpdateRequest => "Update".asJson 82 | case DeleteRequest => "Delete".asJson 83 | case OtherRequestType(req) => req.asJson 84 | } 85 | 86 | implicit val decoder: Decoder[CloudFormationRequestType] = Decoder[String].map { 87 | case "Create" => CreateRequest 88 | case "Update" => UpdateRequest 89 | case "Delete" => DeleteRequest 90 | case other => OtherRequestType(other) 91 | } 92 | } 93 | 94 | sealed trait RequestResponseStatus 95 | object RequestResponseStatus { 96 | case object Success extends RequestResponseStatus 97 | case object Failed extends RequestResponseStatus 98 | 99 | implicit val encoder: Encoder[RequestResponseStatus] = { 100 | case Success => "SUCCESS".asJson 101 | case Failed => "FAILED".asJson 102 | } 103 | 104 | implicit val decoder: Decoder[RequestResponseStatus] = Decoder[String].emap { 105 | case "SUCCESS" => Success.asRight 106 | case "FAILED" => Failed.asRight 107 | case other => s"Invalid response status: $other".asLeft 108 | } 109 | } 110 | 111 | final case class CloudFormationCustomResourceRequest[A]( 112 | RequestType: CloudFormationRequestType, 113 | ResponseURL: Uri, 114 | StackId: StackId, 115 | RequestId: RequestId, 116 | ResourceType: ResourceType, 117 | LogicalResourceId: LogicalResourceId, 118 | PhysicalResourceId: Option[PhysicalResourceId], 119 | ResourceProperties: A, 120 | OldResourceProperties: Option[JsonObject]) 121 | 122 | object CloudFormationCustomResourceRequest extends CirceInstances { 123 | implicit def CloudFormationCustomResourceRequestDecoder[A: Decoder] 124 | : Decoder[CloudFormationCustomResourceRequest[A]] = 125 | Decoder.forProduct9( 126 | "RequestType", 127 | "ResponseURL", 128 | "StackId", 129 | "RequestId", 130 | "ResourceType", 131 | "LogicalResourceId", 132 | "PhysicalResourceId", 133 | "ResourceProperties", 134 | "OldResourceProperties" 135 | )(CloudFormationCustomResourceRequest.apply[A]) 136 | 137 | implicit def CloudFormationCustomResourceRequestEncoder[A: Encoder] 138 | : Encoder[CloudFormationCustomResourceRequest[A]] = 139 | Encoder.forProduct9( 140 | "RequestType", 141 | "ResponseURL", 142 | "StackId", 143 | "RequestId", 144 | "ResourceType", 145 | "LogicalResourceId", 146 | "PhysicalResourceId", 147 | "ResourceProperties", 148 | "OldResourceProperties" 149 | ) { r => 150 | ( 151 | r.RequestType, 152 | r.ResponseURL, 153 | r.StackId, 154 | r.RequestId, 155 | r.ResourceType, 156 | r.LogicalResourceId, 157 | r.PhysicalResourceId, 158 | r.ResourceProperties, 159 | r.OldResourceProperties 160 | ) 161 | } 162 | } 163 | 164 | final case class CloudFormationCustomResourceResponse( 165 | Status: RequestResponseStatus, 166 | Reason: Option[String], 167 | PhysicalResourceId: Option[PhysicalResourceId], 168 | StackId: StackId, 169 | RequestId: RequestId, 170 | LogicalResourceId: LogicalResourceId, 171 | Data: Json) 172 | 173 | object CloudFormationCustomResourceResponse { 174 | implicit val CloudFormationCustomResourceResponseDecoder 175 | : Decoder[CloudFormationCustomResourceResponse] = 176 | Decoder 177 | .forProduct7( 178 | "Status", 179 | "Reason", 180 | "PhysicalResourceId", 181 | "StackId", 182 | "RequestId", 183 | "LogicalResourceId", 184 | "Data" 185 | )(CloudFormationCustomResourceResponse.apply) 186 | .prepare { 187 | _.withFocus { 188 | _.mapObject { obj => 189 | if (obj.contains("Data")) obj 190 | else obj.add("Data", Json.Null) 191 | } 192 | } 193 | } 194 | 195 | implicit val CloudFormationCustomResourceResponseEncoder 196 | : Encoder[CloudFormationCustomResourceResponse] = 197 | Encoder.forProduct7( 198 | "Status", 199 | "Reason", 200 | "PhysicalResourceId", 201 | "StackId", 202 | "RequestId", 203 | "LogicalResourceId", 204 | "Data" 205 | ) { r => 206 | ( 207 | r.Status, 208 | r.Reason, 209 | r.PhysicalResourceId, 210 | r.StackId, 211 | r.RequestId, 212 | r.LogicalResourceId, 213 | r.Data 214 | ) 215 | } 216 | } 217 | 218 | final case class HandlerResponse[A](physicalId: PhysicalResourceId, data: Option[A]) 219 | 220 | object HandlerResponse { 221 | implicit def HandlerResponseCodec[A: Encoder: Decoder]: Codec[HandlerResponse[A]] = 222 | Codec.forProduct2("PhysicalResourceId", "Data")(HandlerResponse.apply[A]) { r => 223 | (r.physicalId, r.data) 224 | } 225 | } 226 | 227 | object MissingResourceProperties extends RuntimeException 228 | } 229 | -------------------------------------------------------------------------------- /lambda-cloudformation-custom-resource/src/test/scala/feral/lambda/cloudformation/ResponseSerializationSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package cloudformation 19 | 20 | import cats._ 21 | import cats.effect._ 22 | import cats.syntax.all._ 23 | import com.eed3si9n.expecty.Expecty.expect 24 | import feral.lambda.cloudformation.CloudFormationRequestType._ 25 | import feral.lambda.cloudformation.ResponseSerializationSuite._ 26 | import io.circe.Json 27 | import io.circe.jawn.CirceSupportParser.facade 28 | import io.circe.jawn.parse 29 | import io.circe.syntax._ 30 | import munit._ 31 | import org.http4s._ 32 | import org.http4s.client.Client 33 | import org.http4s.dsl.io._ 34 | import org.http4s.headers.`Content-Length` 35 | import org.scalacheck.Arbitrary.arbThrowable 36 | import org.scalacheck.effect.PropF 37 | import org.typelevel.jawn.Parser 38 | 39 | import scala.concurrent.duration._ 40 | 41 | class ResponseSerializationSuite 42 | extends CatsEffectSuite 43 | with ScalaCheckEffectSuite 44 | with CloudFormationCustomResourceArbitraries { 45 | 46 | private def captureRequestsAndRespondWithOk( 47 | capture: Deferred[IO, (Request[IO], Json)]): HttpApp[IO] = 48 | HttpApp { req => 49 | for { 50 | body <- req.body.compile.to(Array).flatMap(Parser.parseFromByteArray(_).liftTo[IO]) 51 | _ <- capture.complete((req, body)) 52 | resp <- Ok() 53 | } yield resp 54 | } 55 | 56 | test("CloudFormationCustomResource should PUT the response to the given URI") { 57 | PropF.forAllF { 58 | implicit lambdaEnv: Invocation[IO, CloudFormationCustomResourceRequest[String]] => 59 | for { 60 | eventualRequest <- Deferred[IO, (Request[IO], Json)] 61 | client = Client.fromHttpApp(captureRequestsAndRespondWithOk(eventualRequest)) 62 | _ <- CloudFormationCustomResource(client, new DoNothingCustomResource[IO]) 63 | captured <- eventualRequest.get.timeout(2.seconds) 64 | event <- lambdaEnv.event 65 | } yield { 66 | val (req, body) = captured 67 | val expectedJson = event.RequestType match { 68 | case OtherRequestType(requestType) => 69 | val expectedReason = s"unexpected CloudFormation request type `$requestType`" 70 | val expectedStackTraceHead = 71 | s"java.lang.IllegalArgumentException: $expectedReason" 72 | 73 | Json 74 | .obj( 75 | "Status" -> "FAILED".asJson, 76 | "Reason" -> expectedReason.asJson, 77 | "PhysicalResourceId" -> event.PhysicalResourceId.asJson, 78 | "StackId" -> event.StackId.asJson, 79 | "RequestId" -> event.RequestId.asJson, 80 | "LogicalResourceId" -> event.LogicalResourceId.asJson, 81 | "Data" -> Json.obj { 82 | "StackTrace" -> Json.arr(expectedStackTraceHead.asJson) 83 | } 84 | ) 85 | .deepDropNullValues 86 | case CreateRequest => 87 | Json.obj( 88 | "Status" -> "SUCCESS".asJson, 89 | "PhysicalResourceId" -> convertInputToFakePhysicalResourceId( 90 | event.ResourceProperties).asJson, 91 | "StackId" -> event.StackId.asJson, 92 | "RequestId" -> event.RequestId.asJson, 93 | "LogicalResourceId" -> event.LogicalResourceId.asJson, 94 | "Data" -> event.RequestType.asJson 95 | ) 96 | case UpdateRequest | DeleteRequest => 97 | Json.obj( 98 | "Status" -> "SUCCESS".asJson, 99 | "PhysicalResourceId" -> event.PhysicalResourceId.get.asJson, 100 | "StackId" -> event.StackId.asJson, 101 | "RequestId" -> event.RequestId.asJson, 102 | "LogicalResourceId" -> event.LogicalResourceId.asJson, 103 | "Data" -> event.RequestType.asJson 104 | ) 105 | } 106 | 107 | expect(body eqv expectedJson) 108 | expect(req.method == PUT) 109 | expect(req.uri == event.ResponseURL) 110 | expect(req.headers.get[`Content-Length`].exists(_.length <= 4096)) 111 | } 112 | } 113 | } 114 | 115 | test("Round-trip CloudFormationCustomResourceResponse JSON as emitted by our middleware") { 116 | PropF.forAllF { 117 | ( 118 | res: Either[Throwable, HandlerResponse[Unit]], 119 | req: CloudFormationCustomResourceRequest[Unit]) => 120 | val generatedResponse = res match { 121 | case Left(ex) => CloudFormationCustomResource.exceptionResponse(req)(ex) 122 | case Right(a) => CloudFormationCustomResource.successResponse(req)(a) 123 | } 124 | 125 | CloudFormationCustomResource 126 | .jsonEncoder[IO] 127 | .toEntity(generatedResponse.asJson) 128 | .body 129 | .through(fs2.text.utf8.decode) 130 | .compile 131 | .string 132 | .map(parse(_)) 133 | .map { parseResult => 134 | expect( 135 | parseResult.flatMap(_.as[CloudFormationCustomResourceResponse]) == Right( 136 | generatedResponse)) 137 | } 138 | } 139 | } 140 | } 141 | 142 | object ResponseSerializationSuite { 143 | def convertInputToFakePhysicalResourceId(input: String): PhysicalResourceId = 144 | PhysicalResourceId(input).getOrElse( 145 | PhysicalResourceId.unsafeApply(s"input `$input` was an invalid PhysicalResourceId")) 146 | 147 | class DoNothingCustomResource[F[_]: Applicative] 148 | extends CloudFormationCustomResource[F, String, CloudFormationRequestType] { 149 | override def createResource(input: String): F[HandlerResponse[CloudFormationRequestType]] = 150 | HandlerResponse( 151 | convertInputToFakePhysicalResourceId(input), 152 | CreateRequest.some.widen[CloudFormationRequestType]).pure[F] 153 | 154 | override def updateResource( 155 | input: String, 156 | physicalResourceId: PhysicalResourceId): F[HandlerResponse[CloudFormationRequestType]] = 157 | HandlerResponse(physicalResourceId, UpdateRequest.some.widen[CloudFormationRequestType]) 158 | .pure[F] 159 | 160 | override def deleteResource( 161 | input: String, 162 | physicalResourceId: PhysicalResourceId): F[HandlerResponse[CloudFormationRequestType]] = 163 | HandlerResponse(physicalResourceId, DeleteRequest.some.widen[CloudFormationRequestType]) 164 | .pure[F] 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package http4s 19 | 20 | import cats.effect.kernel.Concurrent 21 | import cats.syntax.all._ 22 | import feral.lambda.ApiGatewayProxyInvocation 23 | import feral.lambda.events.ApiGatewayProxyEvent 24 | import feral.lambda.events.ApiGatewayProxyResult 25 | import feral.lambda.events.ApiGatewayProxyStructuredResultV2 26 | import fs2.Stream 27 | import org.http4s.Charset 28 | import org.http4s.Header 29 | import org.http4s.Headers 30 | import org.http4s.HttpApp 31 | import org.http4s.HttpRoutes 32 | import org.http4s.Method 33 | import org.http4s.Request 34 | import org.http4s.Uri 35 | 36 | object ApiGatewayProxyHandler { 37 | 38 | def apply[F[_]: Concurrent: ApiGatewayProxyInvocation]( 39 | app: HttpApp[F]): F[Option[ApiGatewayProxyResult]] = 40 | for { 41 | event <- Invocation.event 42 | request <- decodeEvent(event) 43 | response <- app(request) 44 | isBase64Encoded = !response.charset.contains(Charset.`UTF-8`) 45 | responseBody <- response 46 | .body 47 | .through( 48 | if (isBase64Encoded) fs2.text.base64.encode else fs2.text.utf8.decode 49 | ) 50 | .compile 51 | .string 52 | } yield { 53 | val headers = response.headers.headers.groupMap(_.name)(_.value) 54 | Some( 55 | ApiGatewayProxyResult( 56 | response.status.code, 57 | headers.map { case (name, values) => name -> values.mkString(",") }, 58 | responseBody, 59 | isBase64Encoded 60 | ) 61 | ) 62 | } 63 | 64 | private[http4s] def decodeEvent[F[_]: Concurrent]( 65 | event: ApiGatewayProxyEvent): F[Request[F]] = { 66 | val queryString: String = getMultiValueQueryStringParameters( 67 | event.multiValueQueryStringParameters) 68 | 69 | val uriString: String = event.path + (if (queryString.nonEmpty) s"?$queryString" else "") 70 | 71 | for { 72 | method <- Method.fromString(event.httpMethod).liftTo[F] 73 | uri <- Uri.fromString(uriString).liftTo[F] 74 | headers = { 75 | val builder = List.newBuilder[Header.Raw] 76 | event.multiValueHeaders.foreach { hMap => 77 | hMap.foreach { 78 | case (key, values) => values.foreach(value => builder += Header.Raw(key, value)) 79 | } 80 | } 81 | Headers(builder.result()) 82 | } 83 | readBody = 84 | if (event.isBase64Encoded) 85 | fs2.text.base64.decode[F] 86 | else 87 | fs2.text.utf8.encode[F] 88 | } yield Request( 89 | method, 90 | uri, 91 | headers = headers, 92 | body = Stream.fromOption[F](event.body).through(readBody) 93 | ) 94 | } 95 | 96 | private def getMultiValueQueryStringParameters( 97 | multiValueQueryStringParameters: Option[Map[String, List[String]]]): String = 98 | multiValueQueryStringParameters.fold("") { params => 99 | params 100 | .flatMap { 101 | case (key, values) => 102 | values.map(value => s"$key=$value") 103 | } 104 | .mkString("&") 105 | } 106 | 107 | @deprecated("Use ApiGatewayProxyHandlerV2", "0.3.0") 108 | def apply[F[_]: ApiGatewayProxyInvocationV2: Concurrent]( 109 | routes: HttpRoutes[F]): F[Option[ApiGatewayProxyStructuredResultV2]] = httpRoutes(routes) 110 | 111 | @deprecated("Use ApiGatewayProxyHandlerV2", "0.3.0") 112 | def httpRoutes[F[_]: Concurrent: ApiGatewayProxyInvocationV2]( 113 | routes: HttpRoutes[F]): F[Option[ApiGatewayProxyStructuredResultV2]] = 114 | ApiGatewayProxyHandlerV2.httpRoutes(routes) 115 | 116 | @deprecated("Use ApiGatewayProxyHandlerV2", "0.3.0") 117 | def httpApp[F[_]: Concurrent: ApiGatewayProxyInvocationV2]( 118 | app: HttpApp[F]): F[Option[ApiGatewayProxyStructuredResultV2]] = 119 | ApiGatewayProxyHandlerV2(app) 120 | } 121 | -------------------------------------------------------------------------------- /lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandlerV2.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package http4s 19 | 20 | import cats.effect.kernel.Concurrent 21 | import cats.syntax.all._ 22 | import feral.lambda.events.ApiGatewayProxyEventV2 23 | import feral.lambda.events.ApiGatewayProxyStructuredResultV2 24 | import fs2.Stream 25 | import org.http4s.Charset 26 | import org.http4s.Header 27 | import org.http4s.Headers 28 | import org.http4s.HttpApp 29 | import org.http4s.HttpRoutes 30 | import org.http4s.Method 31 | import org.http4s.Request 32 | import org.http4s.Uri 33 | import org.http4s.headers.Cookie 34 | import org.http4s.headers.`Set-Cookie` 35 | 36 | object ApiGatewayProxyHandlerV2 { 37 | 38 | @deprecated("Use apply(routes.orNotFound)", "0.3.0") 39 | def httpRoutes[F[_]: Concurrent: ApiGatewayProxyInvocationV2]( 40 | routes: HttpRoutes[F]): F[Option[ApiGatewayProxyStructuredResultV2]] = apply( 41 | routes.orNotFound) 42 | 43 | def apply[F[_]: Concurrent: ApiGatewayProxyInvocationV2]( 44 | app: HttpApp[F]): F[Option[ApiGatewayProxyStructuredResultV2]] = 45 | for { 46 | event <- Invocation.event 47 | request <- decodeEvent(event) 48 | response <- app(request) 49 | isBase64Encoded = !response.charset.contains(Charset.`UTF-8`) 50 | responseBody <- response 51 | .body 52 | .through( 53 | if (isBase64Encoded) fs2.text.base64.encode else fs2.text.utf8.decode 54 | ) 55 | .compile 56 | .string 57 | } yield { 58 | val headers = response.headers.headers.groupMap(_.name)(_.value) 59 | Some( 60 | ApiGatewayProxyStructuredResultV2( 61 | response.status.code, 62 | (headers - `Set-Cookie`.name).map { 63 | case (name, values) => 64 | name -> values.mkString(",") 65 | }, 66 | responseBody, 67 | isBase64Encoded, 68 | headers.getOrElse(`Set-Cookie`.name, Nil) 69 | ) 70 | ) 71 | } 72 | 73 | private[http4s] def decodeEvent[F[_]: Concurrent]( 74 | event: ApiGatewayProxyEventV2): F[Request[F]] = for { 75 | method <- Method.fromString(event.requestContext.http.method).liftTo[F] 76 | uri <- Uri.fromString(event.rawPath + "?" + event.rawQueryString).liftTo[F] 77 | headers = { 78 | val builder = List.newBuilder[Header.Raw] 79 | 80 | event.headers.foreachEntry(builder += Header.Raw(_, _)) 81 | event.cookies.filter(_.nonEmpty).foreach { cs => 82 | builder += Header.Raw(Cookie.name, cs.mkString("; ")) 83 | } 84 | 85 | Headers(builder.result()) 86 | } 87 | readBody = 88 | if (event.isBase64Encoded) 89 | fs2.text.base64.decode[F] 90 | else 91 | fs2.text.utf8.encode[F] 92 | } yield Request( 93 | method, 94 | uri, 95 | headers = headers, 96 | body = Stream.fromOption[F](event.body).through(readBody) 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /lambda-http4s/src/test/scala/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package http4s 19 | 20 | import cats.effect.IO 21 | import cats.syntax.all._ 22 | import feral.lambda.events.ApiGatewayProxyEvent 23 | import feral.lambda.events.ApiGatewayProxyEventSuite._ 24 | import munit.CatsEffectSuite 25 | import org.http4s.Headers 26 | import org.http4s.HttpApp 27 | import org.http4s.Method 28 | import org.http4s.syntax.all._ 29 | 30 | class ApiGatewayProxyHandlerSuite extends CatsEffectSuite { 31 | 32 | val expectedHeaders: Headers = Headers( 33 | "Accept-Language" -> "en-US,en;q=0.8", 34 | "CloudFront-Is-Mobile-Viewer" -> "false", 35 | "CloudFront-Is-Desktop-Viewer" -> "true", 36 | "Via" -> "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", 37 | "X-Amz-Cf-Id" -> "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", 38 | "Host" -> "1234567890.execute-api.us-east-1.amazonaws.com", 39 | "Accept-Encoding" -> "gzip, deflate, sdch", 40 | "X-MultiHeader" -> "foo", 41 | "X-MultiHeader" -> "bar", 42 | "X-Forwarded-Port" -> "443", 43 | "Cache-Control" -> "max-age=0", 44 | "CloudFront-Viewer-Country" -> "US", 45 | "CloudFront-Is-SmartTV-Viewer" -> "false", 46 | "X-Forwarded-Proto" -> "https", 47 | "Upgrade-Insecure-Requests" -> "1", 48 | "User-Agent" -> "Custom User Agent String", 49 | "CloudFront-Forwarded-Proto" -> "https", 50 | "X-Forwarded-For" -> "127.0.0.1, 127.0.0.2", 51 | "CloudFront-Is-Tablet-Viewer" -> "false", 52 | "Accept" -> "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" 53 | ) 54 | 55 | val expectedBody: String = """{"test":"body"}""" 56 | 57 | test("decode event") { 58 | for { 59 | event <- event.as[ApiGatewayProxyEvent].liftTo[IO] 60 | request <- ApiGatewayProxyHandler.decodeEvent[IO](event) 61 | _ <- IO(assertEquals(request.method, Method.POST)) 62 | _ <- IO(assertEquals(request.uri, uri"/path/to/resource?foo=bar&foo=baz")) 63 | _ <- IO(assertEquals(request.headers, expectedHeaders)) 64 | responseBody <- request.bodyText.compile.string 65 | _ <- IO(assertEquals(responseBody, expectedBody)) 66 | } yield () 67 | } 68 | 69 | // compile-only test 70 | def handler(implicit inv: ApiGatewayProxyInvocation[IO]) = 71 | ApiGatewayProxyHandler(HttpApp.notFound[IO]) 72 | 73 | } 74 | -------------------------------------------------------------------------------- /lambda-http4s/src/test/scala/feral/lambda/http4s/ApiGatewayProxyHandlerV2Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.http4s 18 | 19 | import cats.effect.IO 20 | import cats.syntax.all._ 21 | import feral.lambda.events.ApiGatewayProxyEventV2 22 | import feral.lambda.events.ApiGatewayProxyEventV2Suite 23 | import munit.CatsEffectSuite 24 | import org.http4s.Headers 25 | import org.http4s.Method 26 | import org.http4s.syntax.all._ 27 | 28 | class ApiGatewayProxyHandlerV2Suite extends CatsEffectSuite { 29 | 30 | import ApiGatewayProxyEventV2Suite._ 31 | 32 | test("decode event") { 33 | for { 34 | event <- event.as[ApiGatewayProxyEventV2].liftTo[IO] 35 | request <- ApiGatewayProxyHandlerV2.decodeEvent[IO](event) 36 | _ <- IO(assertEquals(request.method, Method.GET)) 37 | _ <- IO(assertEquals(request.uri, uri"/default/nodejs-apig-function-1G3XMPLZXVXYI?")) 38 | _ <- IO(assert(request.cookies.nonEmpty)) 39 | _ <- IO(assertEquals(request.headers, expectedHeaders)) 40 | _ <- request.body.compile.count.assertEquals(0L) 41 | } yield () 42 | } 43 | 44 | test("decode event with no cookies") { 45 | for { 46 | event <- eventNoCookies.as[ApiGatewayProxyEventV2].liftTo[IO] 47 | request <- ApiGatewayProxyHandlerV2.decodeEvent[IO](event) 48 | _ <- IO(assert(request.cookies.isEmpty)) 49 | _ <- request.body.compile.count.assertEquals(0L) 50 | } yield () 51 | } 52 | 53 | def expectedHeaders = Headers( 54 | "content-length" -> "0", 55 | "accept-language" -> "en-US,en;q=0.9", 56 | "sec-fetch-dest" -> "document", 57 | "sec-fetch-user" -> "?1", 58 | "x-amzn-trace-id" -> "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e", 59 | "host" -> "r3pmxmplak.execute-api.us-east-2.amazonaws.com", 60 | "sec-fetch-mode" -> "navigate", 61 | "accept-encoding" -> "gzip, deflate, br", 62 | "accept" -> 63 | "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", 64 | "sec-fetch-site" -> "cross-site", 65 | "x-forwarded-port" -> "443", 66 | "x-forwarded-proto" -> "https", 67 | "upgrade-insecure-requests" -> "1", 68 | "user-agent" -> 69 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36", 70 | "x-forwarded-for" -> "205.255.255.176", 71 | "cookie" -> "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2; regStatus=pre-register" 72 | ) 73 | 74 | } 75 | -------------------------------------------------------------------------------- /lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.effect.Sync 20 | import io.circe.JsonObject 21 | import io.circe.scalajs._ 22 | 23 | import scala.concurrent.duration._ 24 | 25 | private[lambda] trait ContextCompanionPlatform { 26 | 27 | private[lambda] def fromJS[F[_]: Sync](context: facade.Context): Context[F] = 28 | Context( 29 | context.functionName, 30 | context.functionVersion, 31 | context.invokedFunctionArn, 32 | context.memoryLimitInMB.toInt, 33 | context.awsRequestId, 34 | context.logGroupName, 35 | context.logStreamName, 36 | context.identity.toOption.map { identity => 37 | CognitoIdentity(identity.cognitoIdentityId, identity.cognitoIdentityPoolId) 38 | }, 39 | context 40 | .clientContext 41 | .toOption 42 | .map { clientContext => 43 | ClientContext( 44 | ClientContextClient( 45 | clientContext.client.installationId, 46 | clientContext.client.appTitle, 47 | clientContext.client.appVersionName, 48 | clientContext.client.appVersionCode, 49 | clientContext.client.appPackageName 50 | ), 51 | ClientContextEnv( 52 | clientContext.env.platformVersion, 53 | clientContext.env.platform, 54 | clientContext.env.make, 55 | clientContext.env.model, 56 | clientContext.env.locale 57 | ), 58 | clientContext 59 | .custom 60 | .toOption 61 | .flatMap(decodeJs[JsonObject](_).toOption) 62 | .getOrElse(JsonObject.empty) 63 | ) 64 | }, 65 | Sync[F].delay(context.getRemainingTimeInMillis().millis) 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.effect.IO 20 | import cats.effect.std.Dispatcher 21 | import cats.syntax.all._ 22 | import io.circe.scalajs._ 23 | 24 | import scala.scalajs.js 25 | import scala.scalajs.js.JSConverters._ 26 | 27 | private[lambda] abstract class IOLambdaPlatform[Event, Result] { 28 | this: IOLambda[Event, Result] => 29 | 30 | final def main(args: Array[String]): Unit = 31 | js.Dynamic.global.exports.updateDynamic(handlerName)(handlerFn) 32 | 33 | protected def handlerName: String = getClass.getSimpleName.init 34 | 35 | private[lambda] lazy val handlerFn 36 | : js.Function2[js.Any, facade.Context, js.Promise[js.UndefOr[js.Any]]] = { 37 | val dispatcherHandle = { 38 | Dispatcher 39 | .parallel[IO](await = false) 40 | .product(handler) 41 | .allocated 42 | .map(_._1) // drop unused finalizer 43 | .unsafeToPromise()(runtime) 44 | } 45 | 46 | (event: js.Any, context: facade.Context) => 47 | dispatcherHandle.`then`[js.Any] { 48 | case (dispatcher, handle) => 49 | val io = 50 | for { 51 | event <- IO.fromEither(decodeJs[Event](event)) 52 | result <- handle(Invocation.pure(event, Context.fromJS(context))) 53 | } yield result.map(_.asJsAny).orUndefined 54 | 55 | dispatcher.unsafeToPromise(io) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lambda/js/src/main/scala/feral/lambda/facade/Context.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.facade 18 | 19 | import scala.scalajs.js 20 | 21 | private[lambda] trait Context extends js.Object { 22 | def functionName: String 23 | def functionVersion: String 24 | def invokedFunctionArn: String 25 | def memoryLimitInMB: String 26 | def awsRequestId: String 27 | def logGroupName: String 28 | def logStreamName: String 29 | def identity: js.UndefOr[CognitoIdentity] 30 | def clientContext: js.UndefOr[ClientContext] 31 | def getRemainingTimeInMillis(): Double 32 | } 33 | 34 | private[lambda] trait CognitoIdentity extends js.Object { 35 | def cognitoIdentityId: String 36 | def cognitoIdentityPoolId: String 37 | } 38 | 39 | private[lambda] trait ClientContext extends js.Object { 40 | def client: ClientContextClient 41 | def custom: js.UndefOr[js.Any] 42 | def env: ClientContextEnv 43 | } 44 | 45 | private[lambda] trait ClientContextClient extends js.Object { 46 | def installationId: String 47 | def appTitle: String 48 | def appVersionName: String 49 | def appVersionCode: String 50 | def appPackageName: String 51 | } 52 | 53 | private[lambda] trait ClientContextEnv extends js.Object { 54 | def platformVersion: String 55 | def platform: String 56 | def make: String 57 | def model: String 58 | def locale: String 59 | } 60 | -------------------------------------------------------------------------------- /lambda/js/src/test/scala/feral/lambda/IOLambdaJsSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.effect.IO 20 | import cats.effect.kernel.Resource 21 | import cats.syntax.all._ 22 | import io.circe.Json 23 | import io.circe.literal._ 24 | import io.circe.scalajs._ 25 | import munit.CatsEffectSuite 26 | 27 | import java.util.concurrent.atomic.AtomicInteger 28 | import scala.scalajs.js 29 | 30 | class IOLambdaJsSuite extends CatsEffectSuite { 31 | 32 | test("initializes handler once") { 33 | 34 | val allocationCounter = new AtomicInteger 35 | val invokeCounter = new AtomicInteger 36 | val lambda = new IOLambda[String, String] { 37 | def handler = Resource 38 | .eval(IO(allocationCounter.getAndIncrement())) 39 | .as(_.event.map(Some(_)) <* IO(invokeCounter.getAndIncrement())) 40 | } 41 | 42 | val chars = 'A' to 'Z' 43 | chars.toList.traverse { c => 44 | IO.fromPromise(IO(lambda.handlerFn(c.toString, DummyContext))) 45 | .assertEquals(c.toString.asInstanceOf[js.UndefOr[js.Any]]) 46 | } *> IO { 47 | assertEquals(allocationCounter.get(), 1) 48 | assertEquals(invokeCounter.get(), chars.length) 49 | } 50 | } 51 | 52 | test("reads input and writes output") { 53 | 54 | val input = json"""{ "foo": "bar" }""" 55 | val output = json"""{ "woozle": "heffalump" }""" 56 | 57 | val lambda = new IOLambda[Json, Json] { 58 | def handler = Resource.pure(_ => IO(Some(output))) 59 | } 60 | 61 | IO.fromPromise( 62 | IO( 63 | lambda.handlerFn( 64 | input.asJsAny, 65 | DummyContext 66 | ) 67 | ) 68 | ).map(decodeJs[Json](_)) 69 | .assertEquals(Right(output)) 70 | } 71 | 72 | object DummyContext extends facade.Context { 73 | def functionName = "" 74 | def functionVersion = "" 75 | def invokedFunctionArn = "" 76 | def memoryLimitInMB = "0" 77 | def awsRequestId = "" 78 | def logGroupName = "" 79 | def logStreamName = "" 80 | def identity = js.undefined 81 | def clientContext = js.undefined 82 | def getRemainingTimeInMillis(): Double = 0 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /lambda/jvm/src/main/scala/feral/lambda/ContextPlatform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.effect.Sync 20 | import com.amazonaws.services.lambda.runtime 21 | import io.circe.JsonObject 22 | import io.circe.jawn.parse 23 | 24 | import scala.concurrent.duration._ 25 | import scala.jdk.CollectionConverters._ 26 | 27 | private[lambda] trait ContextCompanionPlatform { 28 | 29 | private[lambda] def fromJava[F[_]: Sync](context: runtime.Context): Context[F] = 30 | Context( 31 | context.getFunctionName(), 32 | context.getFunctionVersion(), 33 | context.getInvokedFunctionArn(), 34 | context.getMemoryLimitInMB(), 35 | context.getAwsRequestId(), 36 | context.getLogGroupName(), 37 | context.getLogStreamName(), 38 | Option(context.getIdentity()).map { identity => 39 | CognitoIdentity(identity.getIdentityId(), identity.getIdentityPoolId()) 40 | }, 41 | Option(context.getClientContext()).map { clientContext => 42 | ClientContext( 43 | ClientContextClient( 44 | clientContext.getClient().getInstallationId(), 45 | clientContext.getClient().getAppTitle(), 46 | clientContext.getClient().getAppVersionName(), 47 | clientContext.getClient().getAppVersionCode(), 48 | clientContext.getClient().getAppPackageName() 49 | ), 50 | ClientContextEnv( 51 | clientContext.getEnvironment().get("platformVersion"), 52 | clientContext.getEnvironment().get("platform"), 53 | clientContext.getEnvironment().get("make"), 54 | clientContext.getEnvironment().get("model"), 55 | clientContext.getEnvironment().get("locale") 56 | ), 57 | JsonObject.fromIterable(clientContext.getCustom().asScala.view.flatMap { 58 | case (k, v) => 59 | parse(v).toOption.map(k -> _) 60 | }) 61 | ) 62 | }, 63 | Sync[F].delay(context.getRemainingTimeInMillis().millis) 64 | ) 65 | 66 | } 67 | -------------------------------------------------------------------------------- /lambda/jvm/src/main/scala/feral/lambda/IOLambdaPlatform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.effect.Async 20 | import cats.effect.IO 21 | import cats.effect.Resource 22 | import cats.effect.std.Dispatcher 23 | import cats.effect.syntax.all._ 24 | import cats.syntax.all._ 25 | import com.amazonaws.services.lambda.{runtime => lambdaRuntime} 26 | import io.circe.Printer 27 | import io.circe.jawn 28 | import io.circe.syntax._ 29 | 30 | import java.io.InputStream 31 | import java.io.OutputStream 32 | import java.io.OutputStreamWriter 33 | import java.nio.channels.Channels 34 | import scala.concurrent.duration._ 35 | import scala.util.control.NonFatal 36 | 37 | private[lambda] abstract class IOLambdaPlatform[Event, Result] 38 | extends lambdaRuntime.RequestStreamHandler { this: IOLambda[Event, Result] => 39 | 40 | private[this] val (dispatcher, handle) = { 41 | val handler = { 42 | val h = 43 | try this.handler 44 | catch { case ex if NonFatal(ex) => null } 45 | 46 | if (h ne null) { 47 | h.map(IO.pure(_)) 48 | } else { 49 | val lambdaName = getClass().getSimpleName() 50 | val msg = 51 | s"""|There was an error initializing `$lambdaName` during startup. 52 | |Falling back to initialize-during-first-invocation strategy. 53 | |To fix, try replacing any `val`s in `$lambdaName` with `def`s.""".stripMargin 54 | System.err.println(msg) 55 | 56 | Async[Resource[IO, *]].defer(this.handler).memoize.map(_.allocated.map(_._1)) 57 | } 58 | } 59 | 60 | Dispatcher 61 | .parallel[IO](await = false) 62 | .product(handler) 63 | .allocated 64 | .map(_._1) // drop unused finalizer 65 | .unsafeRunSync()(runtime) 66 | } 67 | 68 | final def handleRequest( 69 | input: InputStream, 70 | output: OutputStream, 71 | runtimeContext: lambdaRuntime.Context): Unit = { 72 | val event = jawn.decodeChannel[Event](Channels.newChannel(input)).fold(throw _, identity(_)) 73 | val context = Context.fromJava[IO](runtimeContext) 74 | dispatcher 75 | .unsafeRunTimed( 76 | handle.flatMap(_(Invocation.pure(event, context))), 77 | runtimeContext.getRemainingTimeInMillis().millis 78 | ) 79 | .foreach { result => 80 | val writer = new OutputStreamWriter(output) 81 | Printer.noSpaces.unsafePrintToAppendable(result.asJson, writer) 82 | writer.flush() 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /lambda/jvm/src/test/scala/feral/lambda/IOLambdaJvmSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.effect.IO 20 | import cats.effect.kernel.Resource 21 | import cats.syntax.all._ 22 | import com.amazonaws.services.lambda.runtime 23 | import io.circe.Json 24 | import io.circe.jawn 25 | import io.circe.literal._ 26 | import munit.FunSuite 27 | 28 | import java.io.ByteArrayInputStream 29 | import java.io.ByteArrayOutputStream 30 | import java.util.concurrent.atomic.AtomicInteger 31 | 32 | class IOLambdaJvmSuite extends FunSuite { 33 | 34 | implicit class HandleOps[A, B](lambda: IOLambda[A, B]) { 35 | def handleRequestHelper(in: String): String = { 36 | val os = new ByteArrayOutputStream 37 | lambda.handleRequest( 38 | new ByteArrayInputStream(in.getBytes()), 39 | os, 40 | DummyContext 41 | ) 42 | new String(os.toByteArray()) 43 | } 44 | } 45 | 46 | test("initializes handler once during construction") { 47 | 48 | val allocationCounter = new AtomicInteger 49 | val invokeCounter = new AtomicInteger 50 | val lambda = new IOLambda[String, String] { 51 | def handler = Resource 52 | .eval(IO(allocationCounter.getAndIncrement())) 53 | .as(_.event.map(Some(_)) <* IO(invokeCounter.getAndIncrement())) 54 | } 55 | 56 | assertEquals(allocationCounter.get(), 1) 57 | 58 | val chars = 'A' to 'Z' 59 | chars.foreach { c => 60 | val json = s""""$c"""" 61 | assertEquals(lambda.handleRequestHelper(json), json) 62 | } 63 | 64 | assertEquals(allocationCounter.get(), 1) 65 | assertEquals(invokeCounter.get(), chars.length) 66 | } 67 | 68 | test("reads input and writes output") { 69 | 70 | val input = json"""{ "foo": "bar" }""" 71 | val output = json"""{ "woozle": "heffalump" }""" 72 | 73 | val lambda = new IOLambda[Json, Json] { 74 | def handler = Resource.pure(_ => IO(Some(output))) 75 | } 76 | 77 | assertEquals( 78 | jawn.parse(lambda.handleRequestHelper(input.noSpaces)), 79 | Right(output) 80 | ) 81 | } 82 | 83 | test("gracefully handles broken initialization due to `val`") { 84 | 85 | def go(mkLambda: AtomicInteger => IOLambda[Unit, Unit]): Unit = { 86 | val counter = new AtomicInteger 87 | val lambda = mkLambda(counter) 88 | assertEquals(counter.get(), 0) // init failed 89 | lambda.handleRequestHelper("{}") 90 | assertEquals(counter.get(), 1) // inited 91 | lambda.handleRequestHelper("{}") 92 | assertEquals(counter.get(), 1) // did not re-init 93 | } 94 | 95 | go { counter => 96 | new IOLambda[Unit, Unit] { 97 | val handler = Resource.eval(IO(counter.getAndIncrement())).as(_ => IO(None)) 98 | } 99 | } 100 | 101 | go { counter => 102 | new IOLambda[Unit, Unit] { 103 | def handler = resource.as(_ => IO(None)) 104 | val resource = Resource.eval(IO(counter.getAndIncrement())) 105 | } 106 | } 107 | } 108 | 109 | object DummyContext extends runtime.Context { 110 | override def getAwsRequestId(): String = "" 111 | override def getLogGroupName(): String = "" 112 | override def getLogStreamName(): String = "" 113 | override def getFunctionName(): String = "" 114 | override def getFunctionVersion(): String = "" 115 | override def getInvokedFunctionArn(): String = "" 116 | override def getIdentity(): runtime.CognitoIdentity = null 117 | override def getClientContext(): runtime.ClientContext = null 118 | override def getRemainingTimeInMillis(): Int = Int.MaxValue 119 | override def getMemoryLimitInMB(): Int = 0 120 | override def getLogger(): runtime.LambdaLogger = null 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala-2/feral/lambda/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral 18 | 19 | import feral.lambda.events._ 20 | import io.circe.Encoder 21 | 22 | import scala.annotation.nowarn 23 | 24 | package object lambda { 25 | 26 | /** 27 | * Alias for `Nothing` which works better with type inference. Inspired by fs2, but inlined 28 | * here to avoid pulling in an otherwise-unnecessary dependency. 29 | */ 30 | type INothing <: Nothing 31 | 32 | /** 33 | * This can't actually be used. It's here because `IOLambda` demands an Encoder for its result 34 | * type, which should be `Nothing` when no output is desired. Userland code will return an 35 | * `Option[Nothing]` which is only inhabited by `None`, and the encoder is only used when the 36 | * userland code returns `Some`. 37 | */ 38 | @nowarn("msg=dead code following this construct") 39 | implicit val nothingEncoder: Encoder[INothing] = identity(_) 40 | 41 | type ApiGatewayProxyInvocation[F[_]] = Invocation[F, ApiGatewayProxyEvent] 42 | type ApiGatewayProxyInvocationV2[F[_]] = Invocation[F, ApiGatewayProxyEventV2] 43 | type DynamoDbStreamInvocation[F[_]] = Invocation[F, DynamoDbStreamEvent] 44 | type S3Invocation[F[_]] = Invocation[F, S3Event] 45 | type S3BatchInvocation[F[_]] = Invocation[F, S3BatchEvent] 46 | type SnsInvocation[F[_]] = Invocation[F, SnsEvent] 47 | type SqsInvocation[F[_]] = Invocation[F, SqsEvent] 48 | 49 | @deprecated("Renamed to Invocation", "0.3.0") 50 | type LambdaEnv[F[_], Event] = Invocation[F, Event] 51 | @deprecated("Renamed to Invocation", "0.3.0") 52 | val LambdaEnv = Invocation 53 | 54 | @deprecated("Renamed to ApiGatewayProxyInvocationV2", "0.3.0") 55 | type ApiGatewayProxyLambdaEnv[F[_]] = ApiGatewayProxyInvocationV2[F] 56 | @deprecated("Renamed to DynamoDbStreamInvocation", "0.3.0") 57 | type DynamoDbStreamLambdaEnv[F[_]] = DynamoDbStreamInvocation[F] 58 | @deprecated( 59 | "Moved to kinesis4cats. See https://etspaceman.github.io/kinesis4cats/feral/getting-started.html.", 60 | since = "0.3.0") 61 | type KinesisStreamLambdaEnv[F[_]] = Invocation[F, KinesisStreamEvent] 62 | @deprecated("Renamed to S3BatchInvocation", "0.3.0") 63 | type S3BatchLambdaEnv[F[_]] = S3BatchInvocation[F] 64 | @deprecated("Renamed to SnsInvocation", "0.3.0") 65 | type SnsLambdaEnv[F[_]] = SnsInvocation[F] 66 | @deprecated("Renamed to SqsInvocation", "0.3.0") 67 | type SqsLambdaEnv[F[_]] = SqsInvocation[F] 68 | } 69 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala-3/feral/lambda/INothing.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import io.circe.Encoder 20 | 21 | import scala.annotation.nowarn 22 | 23 | /** 24 | * Alias for `Nothing` which works better with type inference. Inspired by fs2, but inlined here 25 | * to avoid pulling in an otherwise-unnecessary dependency. 26 | */ 27 | type INothing <: Nothing 28 | object INothing { 29 | 30 | /** 31 | * This can't actually be used. It's here because `IOLambda` demands an Encoder for its result 32 | * type, which should be `Nothing` when no output is desired. Userland code will return an 33 | * `Option[Nothing]` which is only inhabited by `None`, and the encoder is only used when the 34 | * userland code returns `Some`. 35 | */ 36 | @nowarn("msg=dead code following this construct") 37 | implicit val nothingEncoder: Encoder[INothing] = identity(_) 38 | } 39 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala-3/feral/lambda/invocations.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import events.* 20 | 21 | type ApiGatewayProxyInvocation[F[_]] = Invocation[F, ApiGatewayProxyEvent] 22 | type ApiGatewayProxyInvocationV2[F[_]] = Invocation[F, ApiGatewayProxyEventV2] 23 | type DynamoDbStreamInvocation[F[_]] = Invocation[F, DynamoDbStreamEvent] 24 | type S3Invocation[F[_]] = Invocation[F, S3Event] 25 | type S3BatchInvocation[F[_]] = Invocation[F, S3BatchEvent] 26 | type SnsInvocation[F[_]] = Invocation[F, SnsEvent] 27 | type SqsInvocation[F[_]] = Invocation[F, SqsEvent] 28 | 29 | @deprecated("Renamed to Invocation", "0.3.0") 30 | type LambdaEnv[F[_], Event] = Invocation[F, Event] 31 | @deprecated("Renamed to Invocation", "0.3.0") 32 | val LambdaEnv = Invocation 33 | 34 | @deprecated("Renamed to ApiGatewayProxyInvocationV2", "0.3.0") 35 | type ApiGatewayProxyLambdaEnv[F[_]] = ApiGatewayProxyInvocationV2[F] 36 | @deprecated("Renamed to DynamoDbStreamInvocation", "0.3.0") 37 | type DynamoDbStreamLambdaEnv[F[_]] = DynamoDbStreamInvocation[F] 38 | @deprecated( 39 | "Moved to kinesis4cats. See https://etspaceman.github.io/kinesis4cats/feral/getting-started.html.", 40 | since = "0.3.0") 41 | type KinesisStreamLambdaEnv[F[_]] = Invocation[F, KinesisStreamEvent] 42 | @deprecated("Renamed to S3BatchInvocation", "0.3.0") 43 | type S3BatchLambdaEnv[F[_]] = S3BatchInvocation[F] 44 | @deprecated("Renamed to SnsInvocation", "0.3.0") 45 | type SnsLambdaEnv[F[_]] = SnsInvocation[F] 46 | @deprecated("Renamed to SqsInvocation", "0.3.0") 47 | type SqsLambdaEnv[F[_]] = SqsInvocation[F] 48 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/AwsTags.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import natchez.TraceValue 20 | 21 | object AwsTags { 22 | private[this] val prefix = "aws" 23 | def arn(s: String): (String, TraceValue) = s"$prefix.arn" -> s 24 | def requestId(s: String): (String, TraceValue) = s"$prefix.requestId" -> s 25 | } 26 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/Context.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.Applicative 20 | import cats.~> 21 | import io.circe.JsonObject 22 | 23 | import scala.concurrent.duration.FiniteDuration 24 | 25 | sealed abstract class Context[F[_]] { 26 | def functionName: String 27 | def functionVersion: String 28 | def invokedFunctionArn: String 29 | def memoryLimitInMB: Int 30 | def awsRequestId: String 31 | def logGroupName: String 32 | def logStreamName: String 33 | def identity: Option[CognitoIdentity] 34 | def clientContext: Option[ClientContext] 35 | def remainingTime: F[FiniteDuration] 36 | 37 | final def mapK[G[_]](f: F ~> G): Context[G] = 38 | new Context.Impl( 39 | functionName, 40 | functionVersion, 41 | invokedFunctionArn, 42 | memoryLimitInMB, 43 | awsRequestId, 44 | logGroupName, 45 | logStreamName, 46 | identity, 47 | clientContext, 48 | f(remainingTime) 49 | ) 50 | 51 | } 52 | 53 | object Context extends ContextCompanionPlatform { 54 | def apply[F[_]]( 55 | functionName: String, 56 | functionVersion: String, 57 | invokedFunctionArn: String, 58 | memoryLimitInMB: Int, 59 | awsRequestId: String, 60 | logGroupName: String, 61 | logStreamName: String, 62 | identity: Option[CognitoIdentity], 63 | clientContext: Option[ClientContext], 64 | remainingTime: F[FiniteDuration] 65 | )(implicit F: Applicative[F]): Context[F] = { 66 | val _ = F // might be useful for future compatibility 67 | new Impl( 68 | functionName, 69 | functionVersion, 70 | invokedFunctionArn, 71 | memoryLimitInMB, 72 | awsRequestId, 73 | logGroupName, 74 | logStreamName, 75 | identity, 76 | clientContext, 77 | remainingTime) 78 | } 79 | 80 | private final case class Impl[F[_]]( 81 | functionName: String, 82 | functionVersion: String, 83 | invokedFunctionArn: String, 84 | memoryLimitInMB: Int, 85 | awsRequestId: String, 86 | logGroupName: String, 87 | logStreamName: String, 88 | identity: Option[CognitoIdentity], 89 | clientContext: Option[ClientContext], 90 | remainingTime: F[FiniteDuration] 91 | ) extends Context[F] { 92 | override def productPrefix = "Context" 93 | } 94 | } 95 | 96 | sealed abstract class CognitoIdentity { 97 | def identityId: String 98 | def identityPoolId: String 99 | } 100 | 101 | object CognitoIdentity { 102 | def apply(identityId: String, identityPoolId: String): CognitoIdentity = 103 | new Impl(identityId, identityPoolId) 104 | 105 | private final case class Impl( 106 | val identityId: String, 107 | val identityPoolId: String 108 | ) extends CognitoIdentity { 109 | override def productPrefix = "CognitoIdentity" 110 | } 111 | } 112 | 113 | sealed abstract class ClientContext { 114 | def client: ClientContextClient 115 | def env: ClientContextEnv 116 | def custom: JsonObject 117 | } 118 | 119 | object ClientContext { 120 | def apply( 121 | client: ClientContextClient, 122 | env: ClientContextEnv, 123 | custom: JsonObject 124 | ): ClientContext = 125 | new Impl(client, env, custom) 126 | 127 | private final case class Impl( 128 | client: ClientContextClient, 129 | env: ClientContextEnv, 130 | custom: JsonObject 131 | ) extends ClientContext { 132 | override def productPrefix = "ClientContext" 133 | } 134 | } 135 | 136 | sealed abstract class ClientContextClient { 137 | def installationId: String 138 | def appTitle: String 139 | def appVersionName: String 140 | def appVersionCode: String 141 | def appPackageName: String 142 | } 143 | 144 | object ClientContextClient { 145 | def apply( 146 | installationId: String, 147 | appTitle: String, 148 | appVersionName: String, 149 | appVersionCode: String, 150 | appPackageName: String 151 | ): ClientContextClient = 152 | new Impl(installationId, appTitle, appVersionName, appVersionCode, appPackageName) 153 | 154 | private final case class Impl( 155 | installationId: String, 156 | appTitle: String, 157 | appVersionName: String, 158 | appVersionCode: String, 159 | appPackageName: String 160 | ) extends ClientContextClient { 161 | override def productPrefix = "ClientContextClient" 162 | } 163 | } 164 | 165 | sealed abstract class ClientContextEnv { 166 | def platformVersion: String 167 | def platform: String 168 | def make: String 169 | def model: String 170 | def locale: String 171 | } 172 | 173 | object ClientContextEnv { 174 | def apply( 175 | platformVersion: String, 176 | platform: String, 177 | make: String, 178 | model: String, 179 | locale: String): ClientContextEnv = 180 | new Impl(platformVersion, platform, make, model, locale) 181 | 182 | private final case class Impl( 183 | platformVersion: String, 184 | platform: String, 185 | make: String, 186 | model: String, 187 | locale: String 188 | ) extends ClientContextEnv { 189 | override def productPrefix = "ClientContextEnv" 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/IOLambda.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral 18 | package lambda 19 | 20 | import cats.effect.IO 21 | import cats.effect.kernel.Resource 22 | import cats.effect.unsafe.IORuntime 23 | import io.circe.Decoder 24 | import io.circe.Encoder 25 | 26 | abstract class IOLambda[Event, Result]( 27 | implicit private[lambda] val decoder: Decoder[Event], 28 | private[lambda] val encoder: Encoder[Result] 29 | ) extends IOLambdaPlatform[Event, Result] { 30 | 31 | protected def runtime: IORuntime = IORuntime.global 32 | 33 | def handler: Resource[IO, Invocation[IO, Event] => IO[Option[Result]]] 34 | 35 | } 36 | 37 | object IOLambda { 38 | 39 | abstract class Simple[Event, Result]( 40 | implicit decoder: Decoder[Event], 41 | encoder: Encoder[Result]) 42 | extends IOLambda[Event, Result] { 43 | 44 | type Init 45 | def init: Resource[IO, Init] = Resource.pure(null.asInstanceOf[Init]) 46 | 47 | final def handler = init.map { init => inv => 48 | for { 49 | event <- inv.event 50 | ctx <- inv.context 51 | result <- apply(event, ctx, init) 52 | } yield result 53 | } 54 | 55 | def apply(event: Event, context: Context[IO], init: Init): IO[Option[Result]] 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/Invocation.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.Applicative 20 | import cats.Functor 21 | import cats.data.EitherT 22 | import cats.data.Kleisli 23 | import cats.data.OptionT 24 | import cats.data.StateT 25 | import cats.data.WriterT 26 | import cats.kernel.Monoid 27 | import cats.syntax.all._ 28 | import cats.~> 29 | 30 | sealed trait Invocation[F[_], Event] { 31 | def event: F[Event] 32 | def context: F[Context[F]] 33 | 34 | def mapK[G[_]](fk: F ~> G): Invocation[G, Event] 35 | } 36 | 37 | object Invocation { 38 | def apply[F[_], Event](implicit inv: Invocation[F, Event]): Invocation[F, Event] = inv 39 | 40 | def event[F[_], Event](implicit inv: Invocation[F, Event]): F[Event] = inv.event 41 | def context[F[_], Event](implicit inv: Invocation[F, Event]): F[Context[F]] = inv.context 42 | 43 | def pure[F[_]: Applicative, Event](e: Event, c: Context[F]): Invocation[F, Event] = 44 | new Invocation[F, Event] { 45 | def event = e.pure[F] 46 | def context = c.pure[F] 47 | def mapK[G[_]](fk: F ~> G) = new MapK(this, fk) 48 | } 49 | 50 | implicit def kleisliInvocation[F[_], A, B]( 51 | implicit inv: Invocation[F, A]): Invocation[Kleisli[F, B, *], A] = 52 | inv.mapK(Kleisli.liftK) 53 | 54 | implicit def optionTInvocation[F[_]: Functor, A]( 55 | implicit inv: Invocation[F, A]): Invocation[OptionT[F, *], A] = 56 | inv.mapK(OptionT.liftK) 57 | 58 | implicit def eitherTInvocation[F[_]: Functor, A, B]( 59 | implicit inv: Invocation[F, A]): Invocation[EitherT[F, B, *], A] = 60 | inv.mapK(EitherT.liftK) 61 | 62 | implicit def writerTInvocation[F[_]: Applicative, A, B: Monoid]( 63 | implicit inv: Invocation[F, A]): Invocation[WriterT[F, B, *], A] = 64 | inv.mapK(WriterT.liftK[F, B]) 65 | 66 | implicit def stateTInvocation[F[_]: Applicative, S, A]( 67 | implicit inv: Invocation[F, A]): Invocation[StateT[F, S, *], A] = 68 | inv.mapK(StateT.liftK[F, S]) 69 | 70 | private final class MapK[F[_]: Functor, G[_], Event]( 71 | underlying: Invocation[F, Event], 72 | fk: F ~> G 73 | ) extends Invocation[G, Event] { 74 | def event = fk(underlying.event) 75 | def context = fk(underlying.context.map(_.mapK(fk))) 76 | def mapK[H[_]](gk: G ~> H) = new MapK(underlying, fk.andThen(gk)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/KernelSource.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import natchez.Kernel 20 | 21 | trait KernelSource[Event] { 22 | def extract(event: Event): Kernel 23 | } 24 | 25 | object KernelSource { 26 | @inline def apply[E](implicit ev: KernelSource[E]): ev.type = ev 27 | 28 | def emptyKernelSource[E]: KernelSource[E] = _ => Kernel(Map.empty) 29 | } 30 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/TracedHandler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.data.Kleisli 20 | import cats.effect.IO 21 | import cats.effect.kernel.MonadCancelThrow 22 | import cats.syntax.all._ 23 | import natchez.EntryPoint 24 | import natchez.Span 25 | import natchez.Trace 26 | 27 | object TracedHandler { 28 | 29 | def apply[Event, Result](entryPoint: EntryPoint[IO])( 30 | handler: Trace[IO] => IO[Option[Result]])( 31 | implicit inv: Invocation[IO, Event], 32 | KS: KernelSource[Event]): IO[Option[Result]] = for { 33 | event <- inv.event 34 | context <- inv.context 35 | kernel = KS.extract(event) 36 | result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { span => 37 | span.put( 38 | AwsTags.arn(context.invokedFunctionArn), 39 | AwsTags.requestId(context.awsRequestId) 40 | ) >> Trace.ioTrace(span) >>= handler 41 | } 42 | } yield result 43 | 44 | def apply[F[_]: MonadCancelThrow, Event, Result]( 45 | entryPoint: EntryPoint[F], 46 | handler: Kleisli[F, Span[F], Option[Result]])( 47 | // inv first helps bind Event for KernelSource. h/t @bpholt 48 | implicit inv: Invocation[F, Event], 49 | KS: KernelSource[Event]): F[Option[Result]] = for { 50 | event <- inv.event 51 | context <- inv.context 52 | kernel = KS.extract(event) 53 | result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { span => 54 | span.put( 55 | AwsTags.arn(context.invokedFunctionArn), 56 | AwsTags.requestId(context.awsRequestId) 57 | ) >> handler(span) 58 | } 59 | } yield result 60 | 61 | } 62 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package events 19 | 20 | import io.circe.Decoder 21 | import natchez.Kernel 22 | import org.typelevel.ci.CIString 23 | 24 | sealed abstract class ApiGatewayProxyEvent { 25 | def body: Option[String] 26 | def resource: String 27 | def path: String 28 | def httpMethod: String 29 | def isBase64Encoded: Boolean 30 | def queryStringParameters: Option[Map[String, String]] 31 | def multiValueQueryStringParameters: Option[Map[String, List[String]]] 32 | def pathParameters: Option[Map[String, String]] 33 | def stageVariables: Option[Map[String, String]] 34 | def headers: Option[Map[CIString, String]] 35 | def multiValueHeaders: Option[Map[CIString, List[String]]] 36 | } 37 | 38 | object ApiGatewayProxyEvent { 39 | 40 | def apply( 41 | body: Option[String], 42 | resource: String, 43 | path: String, 44 | httpMethod: String, 45 | isBase64Encoded: Boolean, 46 | queryStringParameters: Option[Map[String, String]], 47 | multiValueQueryStringParameters: Option[Map[String, List[String]]], 48 | pathParameters: Option[Map[String, String]], 49 | stageVariables: Option[Map[String, String]], 50 | headers: Option[Map[CIString, String]], 51 | multiValueHeaders: Option[Map[CIString, List[String]]]): ApiGatewayProxyEvent = 52 | new Impl( 53 | body, 54 | resource, 55 | path, 56 | httpMethod, 57 | isBase64Encoded, 58 | queryStringParameters, 59 | multiValueQueryStringParameters, 60 | pathParameters, 61 | stageVariables, 62 | headers, 63 | multiValueHeaders 64 | ) 65 | 66 | import codecs.decodeKeyCIString 67 | implicit def decoder: Decoder[ApiGatewayProxyEvent] = Decoder.forProduct11( 68 | "body", 69 | "resource", 70 | "path", 71 | "httpMethod", 72 | "isBase64Encoded", 73 | "queryStringParameters", 74 | "multiValueQueryStringParameters", 75 | "pathParameters", 76 | "stageVariables", 77 | "headers", 78 | "multiValueHeaders" 79 | )(ApiGatewayProxyEvent.apply) 80 | 81 | implicit def kernelSource: KernelSource[ApiGatewayProxyEvent] = 82 | e => Kernel(e.headers.getOrElse(Map.empty)) 83 | 84 | private final case class Impl( 85 | body: Option[String], 86 | resource: String, 87 | path: String, 88 | httpMethod: String, 89 | isBase64Encoded: Boolean, 90 | queryStringParameters: Option[Map[String, String]], 91 | multiValueQueryStringParameters: Option[Map[String, List[String]]], 92 | pathParameters: Option[Map[String, String]], 93 | stageVariables: Option[Map[String, String]], 94 | headers: Option[Map[CIString, String]], 95 | multiValueHeaders: Option[Map[CIString, List[String]]] 96 | ) extends ApiGatewayProxyEvent { 97 | override def productPrefix = "ApiGatewayProxyEvent" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package events 19 | 20 | import io.circe.Decoder 21 | import natchez.Kernel 22 | import org.typelevel.ci.CIString 23 | 24 | sealed abstract class Http { 25 | def method: String 26 | } 27 | 28 | object Http { 29 | def apply(method: String): Http = 30 | new Impl(method) 31 | 32 | private[events] implicit val decoder: Decoder[Http] = 33 | Decoder.forProduct1("method")(Http.apply) 34 | 35 | private final case class Impl(method: String) extends Http { 36 | override def productPrefix = "Http" 37 | } 38 | } 39 | 40 | sealed abstract class RequestContext { 41 | def http: Http 42 | } 43 | 44 | object RequestContext { 45 | def apply(http: Http): RequestContext = 46 | new Impl(http) 47 | 48 | private[events] implicit val decoder: Decoder[RequestContext] = 49 | Decoder.forProduct1("http")(RequestContext.apply) 50 | 51 | private final case class Impl(http: Http) extends RequestContext { 52 | override def productPrefix = "RequestContext" 53 | } 54 | } 55 | 56 | sealed abstract class ApiGatewayProxyEventV2 { 57 | def rawPath: String 58 | def rawQueryString: String 59 | def cookies: Option[List[String]] 60 | def headers: Map[CIString, String] 61 | def requestContext: RequestContext 62 | def body: Option[String] 63 | def isBase64Encoded: Boolean 64 | } 65 | 66 | object ApiGatewayProxyEventV2 { 67 | def apply( 68 | rawPath: String, 69 | rawQueryString: String, 70 | cookies: Option[List[String]], 71 | headers: Map[CIString, String], 72 | requestContext: RequestContext, 73 | body: Option[String], 74 | isBase64Encoded: Boolean 75 | ): ApiGatewayProxyEventV2 = 76 | new Impl(rawPath, rawQueryString, cookies, headers, requestContext, body, isBase64Encoded) 77 | 78 | import codecs.decodeKeyCIString 79 | implicit def decoder: Decoder[ApiGatewayProxyEventV2] = Decoder.forProduct7( 80 | "rawPath", 81 | "rawQueryString", 82 | "cookies", 83 | "headers", 84 | "requestContext", 85 | "body", 86 | "isBase64Encoded" 87 | )(ApiGatewayProxyEventV2.apply) 88 | 89 | implicit def kernelSource: KernelSource[ApiGatewayProxyEventV2] = 90 | e => Kernel(e.headers) 91 | 92 | private final case class Impl( 93 | rawPath: String, 94 | rawQueryString: String, 95 | cookies: Option[List[String]], 96 | headers: Map[CIString, String], 97 | requestContext: RequestContext, 98 | body: Option[String], 99 | isBase64Encoded: Boolean 100 | ) extends ApiGatewayProxyEventV2 { 101 | override def productPrefix = "ApiGatewayProxyEventV2" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.Encoder 20 | import org.typelevel.ci.CIString 21 | 22 | sealed abstract class ApiGatewayProxyResult { 23 | def statusCode: Int 24 | def headers: Map[CIString, String] 25 | def body: String 26 | def isBase64Encoded: Boolean 27 | } 28 | 29 | object ApiGatewayProxyResult { 30 | 31 | def apply( 32 | statusCode: Int, 33 | headers: Map[CIString, String], 34 | body: String, 35 | isBase64Encoded: Boolean): ApiGatewayProxyResult = 36 | new Impl(statusCode, headers, body, isBase64Encoded) 37 | 38 | @deprecated("Use apply method which takes headers", "0.3.0") 39 | def apply(statusCode: Int, body: String, isBase64Encoded: Boolean): ApiGatewayProxyResult = 40 | apply(statusCode, Map.empty, body, isBase64Encoded) 41 | 42 | import codecs.encodeKeyCIString 43 | implicit def encoder: Encoder[ApiGatewayProxyResult] = Encoder.forProduct4( 44 | "statusCode", 45 | "headers", 46 | "body", 47 | "isBase64Encoded" 48 | )(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded)) 49 | 50 | private final case class Impl( 51 | statusCode: Int, 52 | headers: Map[CIString, String], 53 | body: String, 54 | isBase64Encoded: Boolean 55 | ) extends ApiGatewayProxyResult { 56 | override def productPrefix = "ApiGatewayProxyResult" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.Encoder 20 | import org.typelevel.ci.CIString 21 | 22 | sealed abstract class ApiGatewayProxyStructuredResultV2 { 23 | def statusCode: Int 24 | def headers: Map[CIString, String] 25 | def body: String 26 | def isBase64Encoded: Boolean 27 | def cookies: List[String] 28 | } 29 | 30 | object ApiGatewayProxyStructuredResultV2 { 31 | def apply( 32 | statusCode: Int, 33 | headers: Map[CIString, String], 34 | body: String, 35 | isBase64Encoded: Boolean, 36 | cookies: List[String] 37 | ): ApiGatewayProxyStructuredResultV2 = 38 | new Impl(statusCode, headers, body, isBase64Encoded, cookies) 39 | 40 | import codecs.encodeKeyCIString 41 | implicit def encoder: Encoder[ApiGatewayProxyStructuredResultV2] = Encoder.forProduct5( 42 | "statusCode", 43 | "headers", 44 | "body", 45 | "isBase64Encoded", 46 | "cookies" 47 | )(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded, r.cookies)) 48 | 49 | private final case class Impl( 50 | statusCode: Int, 51 | headers: Map[CIString, String], 52 | body: String, 53 | isBase64Encoded: Boolean, 54 | cookies: List[String] 55 | ) extends ApiGatewayProxyStructuredResultV2 { 56 | override def productPrefix = "ApiGatewayProxyStructuredResultV2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayV2WebSocketEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import com.comcast.ip4s.Hostname 20 | import io.circe.Decoder 21 | 22 | import java.time.Instant 23 | 24 | import codecs.decodeInstant 25 | import codecs.decodeHostname 26 | 27 | sealed abstract class ApiGatewayV2WebSocketEvent { 28 | def stageVariables: Option[Map[String, String]] 29 | def requestContext: WebSocketRequestContext 30 | def body: Option[String] 31 | def isBase64Encoded: Boolean 32 | } 33 | 34 | object ApiGatewayV2WebSocketEvent { 35 | def apply( 36 | stageVariables: Option[Map[String, String]], 37 | requestContext: WebSocketRequestContext, 38 | body: Option[String], 39 | isBase64Encoded: Boolean 40 | ): ApiGatewayV2WebSocketEvent = 41 | new Impl( 42 | stageVariables, 43 | requestContext, 44 | body, 45 | isBase64Encoded 46 | ) 47 | 48 | implicit val decoder: Decoder[ApiGatewayV2WebSocketEvent] = Decoder.forProduct4( 49 | "stageVariables", 50 | "requestContext", 51 | "body", 52 | "isBase64Encoded" 53 | )(ApiGatewayV2WebSocketEvent.apply) 54 | 55 | private final case class Impl( 56 | stageVariables: Option[Map[String, String]], 57 | requestContext: WebSocketRequestContext, 58 | body: Option[String], 59 | isBase64Encoded: Boolean 60 | ) extends ApiGatewayV2WebSocketEvent { 61 | override def productPrefix = "ApiGatewayV2WebSocketEvent" 62 | } 63 | } 64 | 65 | sealed abstract class WebSocketEventType 66 | 67 | object WebSocketEventType { 68 | case object Connect extends WebSocketEventType 69 | case object Message extends WebSocketEventType 70 | case object Disconnect extends WebSocketEventType 71 | 72 | private[events] implicit val decoder: Decoder[WebSocketEventType] = 73 | Decoder.decodeString.map { 74 | case "CONNECT" => Connect 75 | case "MESSAGE" => Message 76 | case "DISCONNECT" => Disconnect 77 | } 78 | } 79 | 80 | sealed abstract class WebSocketRequestContext { 81 | def stage: String 82 | def requestId: String 83 | def apiId: String 84 | def connectedAt: Instant 85 | def connectionId: String 86 | def domainName: Hostname 87 | def eventType: WebSocketEventType 88 | def extendedRequestId: String 89 | def messageId: Option[String] 90 | def requestTime: Instant 91 | def routeKey: String 92 | } 93 | 94 | object WebSocketRequestContext { 95 | def apply( 96 | stage: String, 97 | requestId: String, 98 | apiId: String, 99 | connectedAt: Instant, 100 | connectionId: String, 101 | domainName: Hostname, 102 | eventType: WebSocketEventType, 103 | extendedRequestId: String, 104 | messageId: Option[String], 105 | requestTime: Instant, 106 | routeKey: String 107 | ): WebSocketRequestContext = 108 | new Impl( 109 | stage, 110 | requestId, 111 | apiId, 112 | connectedAt, 113 | connectionId, 114 | domainName, 115 | eventType, 116 | extendedRequestId, 117 | messageId, 118 | requestTime, 119 | routeKey 120 | ) 121 | 122 | private[events] implicit val decoder: Decoder[WebSocketRequestContext] = Decoder.forProduct11( 123 | "stage", 124 | "requestId", 125 | "apiId", 126 | "connectedAt", 127 | "connectionId", 128 | "domainName", 129 | "eventType", 130 | "extendedRequestId", 131 | "messageId", 132 | "requestTimeEpoch", 133 | "routeKey" 134 | )(WebSocketRequestContext.apply) 135 | 136 | private final case class Impl( 137 | stage: String, 138 | requestId: String, 139 | apiId: String, 140 | connectedAt: Instant, 141 | connectionId: String, 142 | domainName: Hostname, 143 | eventType: WebSocketEventType, 144 | extendedRequestId: String, 145 | messageId: Option[String], 146 | requestTime: Instant, 147 | routeKey: String 148 | ) extends WebSocketRequestContext { 149 | override def productPrefix = "WebSocketRequestContext" 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package events 19 | 20 | import io.circe.Decoder 21 | import io.circe.Json 22 | import io.circe.scodec.decodeByteVector 23 | import scodec.bits.ByteVector 24 | 25 | sealed abstract class AttributeValue { 26 | def b: Option[ByteVector] 27 | def bs: Option[List[ByteVector]] 28 | def bool: Option[Boolean] 29 | def l: Option[List[AttributeValue]] 30 | def m: Option[Map[String, AttributeValue]] 31 | def n: Option[String] 32 | def ns: Option[List[String]] 33 | def nul: Boolean 34 | def s: Option[String] 35 | def ss: Option[List[String]] 36 | } 37 | 38 | object AttributeValue { 39 | def apply( 40 | b: Option[ByteVector], 41 | bs: Option[List[ByteVector]], 42 | bool: Option[Boolean], 43 | l: Option[List[AttributeValue]], 44 | m: Option[Map[String, AttributeValue]], 45 | n: Option[String], 46 | ns: Option[List[String]], 47 | nul: Boolean, 48 | s: Option[String], 49 | ss: Option[List[String]] 50 | ): AttributeValue = 51 | new Impl(b, bs, bool, l, m, n, ns, nul, s, ss) 52 | 53 | private[events] implicit val decoder: Decoder[AttributeValue] = for { 54 | b <- Decoder[Option[ByteVector]].at("B") 55 | bs <- Decoder[Option[List[ByteVector]]].at("BS") 56 | bool <- Decoder[Option[Boolean]].at("BOOL") 57 | l <- Decoder[Option[List[AttributeValue]]].at("L") 58 | m <- Decoder[Option[Map[String, AttributeValue]]].at("M") 59 | n <- Decoder[Option[String]].at("N") 60 | ns <- Decoder[Option[List[String]]].at("NS") 61 | nul <- Decoder[Option[true]].at("NULL") 62 | s <- Decoder[Option[String]].at("S") 63 | ss <- Decoder[Option[List[String]]].at("SS") 64 | } yield AttributeValue( 65 | b = b, 66 | bs = bs, 67 | bool = bool, 68 | l = l, 69 | m = m, 70 | n = n, 71 | ns = ns, 72 | nul = nul.getOrElse(false), 73 | s = s, 74 | ss = ss 75 | ) 76 | 77 | private final case class Impl( 78 | b: Option[ByteVector], 79 | bs: Option[List[ByteVector]], 80 | bool: Option[Boolean], 81 | l: Option[List[AttributeValue]], 82 | m: Option[Map[String, AttributeValue]], 83 | n: Option[String], 84 | ns: Option[List[String]], 85 | nul: Boolean, 86 | s: Option[String], 87 | ss: Option[List[String]] 88 | ) extends AttributeValue { 89 | override def productPrefix = "AttributeValue" 90 | } 91 | } 92 | 93 | sealed abstract class StreamRecord { 94 | def approximateCreationDateTime: Option[Double] 95 | def keys: Option[Map[String, AttributeValue]] 96 | def newImage: Option[Map[String, AttributeValue]] 97 | def oldImage: Option[Map[String, AttributeValue]] 98 | def sequenceNumber: Option[String] 99 | def sizeBytes: Option[Double] 100 | def streamViewType: Option[String] 101 | } 102 | 103 | object StreamRecord { 104 | def apply( 105 | approximateCreationDateTime: Option[Double], 106 | keys: Option[Map[String, AttributeValue]], 107 | newImage: Option[Map[String, AttributeValue]], 108 | oldImage: Option[Map[String, AttributeValue]], 109 | sequenceNumber: Option[String], 110 | sizeBytes: Option[Double], 111 | streamViewType: Option[String] 112 | ): StreamRecord = 113 | new Impl( 114 | approximateCreationDateTime, 115 | keys, 116 | newImage, 117 | oldImage, 118 | sequenceNumber, 119 | sizeBytes, 120 | streamViewType 121 | ) 122 | 123 | private[events] implicit val decoder: Decoder[StreamRecord] = Decoder.forProduct7( 124 | "ApproximateCreationDateTime", 125 | "Keys", 126 | "NewImage", 127 | "OldImage", 128 | "SequenceNumber", 129 | "SizeBytes", 130 | "StreamViewType" 131 | )(StreamRecord.apply) 132 | 133 | private final case class Impl( 134 | approximateCreationDateTime: Option[Double], 135 | keys: Option[Map[String, AttributeValue]], 136 | newImage: Option[Map[String, AttributeValue]], 137 | oldImage: Option[Map[String, AttributeValue]], 138 | sequenceNumber: Option[String], 139 | sizeBytes: Option[Double], 140 | streamViewType: Option[String] 141 | ) extends StreamRecord { 142 | override def productPrefix = "StreamRecord" 143 | } 144 | } 145 | 146 | sealed abstract class DynamoDbRecord { 147 | def awsRegion: Option[String] 148 | def dynamodb: Option[StreamRecord] 149 | def eventId: Option[String] 150 | def eventName: Option[String] 151 | def eventSource: Option[String] 152 | def eventSourceArn: Option[String] 153 | def eventVersion: Option[String] 154 | def userIdentity: Option[Json] 155 | 156 | @deprecated("Renamed to eventId", "0.3.0") 157 | final def eventID: Option[String] = eventId 158 | } 159 | 160 | object DynamoDbRecord { 161 | def apply( 162 | awsRegion: Option[String], 163 | dynamodb: Option[StreamRecord], 164 | eventId: Option[String], 165 | eventName: Option[String], 166 | eventSource: Option[String], 167 | eventSourceArn: Option[String], 168 | eventVersion: Option[String], 169 | userIdentity: Option[Json] 170 | ): DynamoDbRecord = 171 | new Impl( 172 | awsRegion, 173 | dynamodb, 174 | eventId, 175 | eventName, 176 | eventSource, 177 | eventSourceArn, 178 | eventVersion, 179 | userIdentity 180 | ) 181 | 182 | private[events] implicit val decoder: Decoder[DynamoDbRecord] = Decoder.forProduct8( 183 | "awsRegion", 184 | "dynamodb", 185 | "eventID", 186 | "eventName", 187 | "eventSource", 188 | "eventSourceARN", 189 | "eventVersion", 190 | "userIdentity" 191 | )(DynamoDbRecord.apply) 192 | 193 | private final case class Impl( 194 | awsRegion: Option[String], 195 | dynamodb: Option[StreamRecord], 196 | eventId: Option[String], 197 | eventName: Option[String], 198 | eventSource: Option[String], 199 | eventSourceArn: Option[String], 200 | eventVersion: Option[String], 201 | userIdentity: Option[Json] 202 | ) extends DynamoDbRecord { 203 | override def productPrefix = "DynamoDbRecord" 204 | } 205 | } 206 | 207 | sealed abstract class DynamoDbStreamEvent { 208 | def records: List[DynamoDbRecord] 209 | } 210 | 211 | object DynamoDbStreamEvent { 212 | def apply(records: List[DynamoDbRecord]): DynamoDbStreamEvent = 213 | new Impl(records) 214 | 215 | implicit val decoder: Decoder[DynamoDbStreamEvent] = 216 | Decoder.forProduct1("Records")(DynamoDbStreamEvent.apply) 217 | 218 | implicit def kernelSource: KernelSource[DynamoDbStreamEvent] = KernelSource.emptyKernelSource 219 | 220 | private final case class Impl( 221 | records: List[DynamoDbRecord] 222 | ) extends DynamoDbStreamEvent { 223 | override def productPrefix = "DynamoDbStreamEvent" 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/KafkaEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import cats.syntax.all._ 20 | import com.comcast.ip4s.Host 21 | import com.comcast.ip4s.SocketAddress 22 | import feral.lambda.events.KafkaRecord.TimestampType 23 | import io.circe.Decoder 24 | import io.circe.KeyDecoder 25 | import scodec.bits.ByteVector 26 | 27 | import java.time.Instant 28 | 29 | sealed abstract class MskEvent extends KafkaEvent { 30 | def eventSourceArn: String 31 | } 32 | 33 | object MskEvent { 34 | import KafkaEvent.TopicPartition 35 | import KafkaEvent.bootstrapServersDecoder 36 | 37 | def apply( 38 | records: Map[TopicPartition, List[KafkaRecord]], 39 | eventSourceArn: String, 40 | bootstrapServers: List[SocketAddress[Host]] 41 | ): MskEvent = { 42 | Impl(records, eventSourceArn, bootstrapServers) 43 | } 44 | 45 | private[events] implicit val decoder: Decoder[MskEvent] = 46 | Decoder.forProduct3( 47 | "records", 48 | "eventSourceArn", 49 | "bootstrapServers" 50 | )(MskEvent.apply) 51 | 52 | private final case class Impl( 53 | records: Map[KafkaEvent.TopicPartition, List[KafkaRecord]], 54 | eventSourceArn: String, 55 | bootstrapServers: List[SocketAddress[Host]] 56 | ) extends MskEvent { 57 | override def productPrefix = "MskEvent" 58 | } 59 | 60 | } 61 | 62 | sealed abstract class KafkaEvent { 63 | def records: Map[KafkaEvent.TopicPartition, List[KafkaRecord]] 64 | def bootstrapServers: List[SocketAddress[Host]] 65 | } 66 | 67 | object KafkaEvent { 68 | def apply( 69 | records: Map[TopicPartition, List[KafkaRecord]], 70 | bootstrapServers: List[SocketAddress[Host]] 71 | ): KafkaEvent = { 72 | Impl(records, bootstrapServers) 73 | } 74 | 75 | private[events] implicit val bootstrapServersDecoder: Decoder[List[SocketAddress[Host]]] = 76 | Decoder 77 | .decodeString 78 | .emap(str => 79 | str 80 | .split(",") 81 | .toList 82 | .traverse(SocketAddress.fromString) 83 | .toRight(s"Failed to parse bootstrap servers: $str")) 84 | 85 | private[events] implicit val decoder: Decoder[KafkaEvent] = 86 | Decoder.forProduct2( 87 | "records", 88 | "bootstrapServers" 89 | )(KafkaEvent.apply) 90 | 91 | private final case class Impl( 92 | records: Map[TopicPartition, List[KafkaRecord]], 93 | bootstrapServers: List[SocketAddress[Host]] 94 | ) extends KafkaEvent { 95 | override def productPrefix = "KafkaEvent" 96 | } 97 | 98 | sealed abstract class TopicPartition { 99 | def topic: String 100 | def partition: Int 101 | 102 | override def toString = s"TopicPartition($topic-$partition)" 103 | } 104 | 105 | object TopicPartition { 106 | def apply( 107 | topic: String, 108 | partition: Int 109 | ): TopicPartition = 110 | Impl(topic, partition) 111 | 112 | private[events] implicit val keyDecoder: KeyDecoder[TopicPartition] = key => { 113 | key.lastIndexOf("-") match { 114 | case -1 => None 115 | case i => Some(TopicPartition(key.substring(0, i), key.substring(i + 1).toInt)) 116 | } 117 | } 118 | 119 | private final case class Impl( 120 | topic: String, 121 | partition: Int 122 | ) extends TopicPartition { 123 | override def productPrefix = "TopicPartition" 124 | } 125 | } 126 | } 127 | 128 | sealed abstract class KafkaRecord { 129 | def topic: String 130 | def partition: Int 131 | def offset: Long 132 | def timestamp: Instant 133 | def timestampType: TimestampType 134 | def key: ByteVector 135 | def value: ByteVector 136 | def headers: List[(String, ByteVector)] 137 | } 138 | 139 | object KafkaRecord { 140 | import codecs.decodeInstant 141 | import io.circe.scodec.decodeByteVector 142 | 143 | def apply( 144 | topic: String, 145 | partition: Int, 146 | offset: Long, 147 | timestamp: Instant, 148 | timestampType: TimestampType, 149 | key: ByteVector, 150 | value: ByteVector, 151 | headers: List[(String, ByteVector)] 152 | ): KafkaRecord = { 153 | Impl(topic, partition, offset, timestamp, timestampType, key, value, headers) 154 | } 155 | 156 | private[events] implicit val headersDecoder: Decoder[List[(String, ByteVector)]] = { 157 | val byteHeadersDecoder: Decoder[ByteVector] = 158 | Decoder.decodeArray(Decoder.decodeByte, Array).map(ByteVector(_)) 159 | Decoder 160 | .decodeList( 161 | Decoder.decodeMap( 162 | KeyDecoder.decodeKeyString, 163 | byteHeadersDecoder 164 | ) 165 | ) 166 | .map(_.flatMap(_.toList)) 167 | } 168 | 169 | private[events] implicit val decoder: Decoder[KafkaRecord] = 170 | Decoder.forProduct8( 171 | "topic", 172 | "partition", 173 | "offset", 174 | "timestamp", 175 | "timestampType", 176 | "key", 177 | "value", 178 | "headers" 179 | )(KafkaRecord.apply) 180 | 181 | private final case class Impl( 182 | topic: String, 183 | partition: Int, 184 | offset: Long, 185 | timestamp: Instant, 186 | timestampType: TimestampType, 187 | key: ByteVector, 188 | value: ByteVector, 189 | headers: List[(String, ByteVector)] 190 | ) extends KafkaRecord { 191 | override def productPrefix = "KafkaRecord" 192 | } 193 | 194 | sealed abstract class TimestampType 195 | object TimestampType { 196 | case object CreateTime extends TimestampType 197 | case object LogAppendTime extends TimestampType 198 | 199 | implicit val decoder: Decoder[TimestampType] = Decoder.decodeString.emap { 200 | case "CREATE_TIME" => Right(TimestampType.CreateTime) 201 | case "LOG_APPEND_TIME" => Right(TimestampType.LogAppendTime) 202 | case other => Left(s"Unknown timestamp type: $other") 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/KinesisStreamEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package events 19 | 20 | import io.circe.Decoder 21 | import io.circe.scodec._ 22 | import scodec.bits.ByteVector 23 | 24 | import java.time.Instant 25 | 26 | sealed abstract class KinesisStreamRecordPayload { 27 | def approximateArrivalTimestamp: Instant 28 | def data: ByteVector 29 | def kinesisSchemaVersion: String 30 | def partitionKey: String 31 | def sequenceNumber: String 32 | } 33 | 34 | object KinesisStreamRecordPayload { 35 | def apply( 36 | approximateArrivalTimestamp: Instant, 37 | data: ByteVector, 38 | kinesisSchemaVersion: String, 39 | partitionKey: String, 40 | sequenceNumber: String 41 | ): KinesisStreamRecordPayload = 42 | new Impl( 43 | approximateArrivalTimestamp, 44 | data, 45 | kinesisSchemaVersion, 46 | partitionKey, 47 | sequenceNumber 48 | ) 49 | 50 | import codecs.decodeInstant 51 | private[events] implicit val decoder: Decoder[KinesisStreamRecordPayload] = 52 | Decoder.forProduct5( 53 | "approximateArrivalTimestamp", 54 | "data", 55 | "kinesisSchemaVersion", 56 | "partitionKey", 57 | "sequenceNumber" 58 | )(KinesisStreamRecordPayload.apply) 59 | 60 | private final case class Impl( 61 | approximateArrivalTimestamp: Instant, 62 | data: ByteVector, 63 | kinesisSchemaVersion: String, 64 | partitionKey: String, 65 | sequenceNumber: String 66 | ) extends KinesisStreamRecordPayload { 67 | override def productPrefix = "KinesisStreamRecordPayload" 68 | } 69 | } 70 | 71 | sealed abstract class KinesisStreamRecord { 72 | def awsRegion: String 73 | def eventId: String 74 | def eventName: String 75 | def eventSource: String 76 | def eventSourceArn: String 77 | def eventVersion: String 78 | def invokeIdentityArn: String 79 | def kinesis: KinesisStreamRecordPayload 80 | 81 | @deprecated("Renamed to eventId", "0.3.0") 82 | final def eventID: String = eventId 83 | } 84 | 85 | object KinesisStreamRecord { 86 | def apply( 87 | awsRegion: String, 88 | eventId: String, 89 | eventName: String, 90 | eventSource: String, 91 | eventSourceArn: String, 92 | eventVersion: String, 93 | invokeIdentityArn: String, 94 | kinesis: KinesisStreamRecordPayload 95 | ): KinesisStreamRecord = 96 | new Impl( 97 | awsRegion, 98 | eventId, 99 | eventName, 100 | eventSource, 101 | eventSourceArn, 102 | eventVersion, 103 | invokeIdentityArn, 104 | kinesis 105 | ) 106 | 107 | private[events] implicit val decoder: Decoder[KinesisStreamRecord] = Decoder.forProduct8( 108 | "awsRegion", 109 | "eventID", 110 | "eventName", 111 | "eventSource", 112 | "eventSourceARN", 113 | "eventVersion", 114 | "invokeIdentityArn", 115 | "kinesis" 116 | )(KinesisStreamRecord.apply) 117 | 118 | private final case class Impl( 119 | awsRegion: String, 120 | eventId: String, 121 | eventName: String, 122 | eventSource: String, 123 | eventSourceArn: String, 124 | eventVersion: String, 125 | invokeIdentityArn: String, 126 | kinesis: KinesisStreamRecordPayload 127 | ) extends KinesisStreamRecord { 128 | override def productPrefix = "KinesisStreamRecord" 129 | } 130 | } 131 | 132 | @deprecated( 133 | "Moved to kinesis4cats. See https://etspaceman.github.io/kinesis4cats/feral/getting-started.html.", 134 | since = "0.3.0") 135 | sealed abstract class KinesisStreamEvent { 136 | def records: List[KinesisStreamRecord] 137 | } 138 | 139 | @deprecated( 140 | "Moved to kinesis4cats. See https://etspaceman.github.io/kinesis4cats/feral/getting-started.html.", 141 | since = "0.3.0") 142 | object KinesisStreamEvent { 143 | def apply(records: List[KinesisStreamRecord]): KinesisStreamEvent = 144 | new Impl(records) 145 | 146 | implicit val decoder: Decoder[KinesisStreamEvent] = 147 | Decoder.forProduct1("Records")(KinesisStreamEvent.apply) 148 | 149 | implicit def kernelSource: KernelSource[KinesisStreamEvent] = KernelSource.emptyKernelSource 150 | 151 | private final case class Impl( 152 | records: List[KinesisStreamRecord] 153 | ) extends KinesisStreamEvent { 154 | override def productPrefix = "KinesisStreamEvent" 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/S3BatchEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import feral.lambda.KernelSource 20 | import io.circe.Decoder 21 | 22 | // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/s3-batch.d.ts 23 | 24 | sealed abstract class S3BatchEvent { 25 | def invocationSchemaVersion: String 26 | def invocationId: String 27 | def job: S3BatchEventJob 28 | def tasks: List[S3BatchEventTask] 29 | } 30 | 31 | object S3BatchEvent { 32 | def apply( 33 | invocationSchemaVersion: String, 34 | invocationId: String, 35 | job: S3BatchEventJob, 36 | tasks: List[S3BatchEventTask] 37 | ): S3BatchEvent = 38 | new Impl(invocationSchemaVersion, invocationId, job, tasks) 39 | 40 | implicit val decoder: Decoder[S3BatchEvent] = 41 | Decoder.forProduct4("invocationSchemaVersion", "invocationId", "job", "tasks")( 42 | S3BatchEvent.apply) 43 | 44 | implicit def kernelSource: KernelSource[S3BatchEvent] = KernelSource.emptyKernelSource 45 | 46 | private final case class Impl( 47 | invocationSchemaVersion: String, 48 | invocationId: String, 49 | job: S3BatchEventJob, 50 | tasks: List[S3BatchEventTask] 51 | ) extends S3BatchEvent { 52 | override def productPrefix = "S3BatchEvent" 53 | } 54 | } 55 | 56 | sealed abstract class S3BatchEventJob { 57 | def id: String 58 | } 59 | 60 | object S3BatchEventJob { 61 | def apply(id: String): S3BatchEventJob = new Impl(id) 62 | 63 | private[events] implicit val decoder: Decoder[S3BatchEventJob] = 64 | Decoder.forProduct1("id")(S3BatchEventJob.apply) 65 | 66 | private final case class Impl(id: String) extends S3BatchEventJob { 67 | override def productPrefix = "S3BatchEventJob" 68 | } 69 | } 70 | 71 | sealed abstract class S3BatchEventTask { 72 | def taskId: String 73 | def s3Key: String 74 | def s3VersionId: Option[String] 75 | def s3BucketArn: String 76 | } 77 | 78 | object S3BatchEventTask { 79 | def apply( 80 | taskId: String, 81 | s3Key: String, 82 | s3VersionId: Option[String], 83 | s3BucketArn: String 84 | ): S3BatchEventTask = 85 | new Impl(taskId, s3Key, s3VersionId, s3BucketArn) 86 | 87 | private[events] implicit val decoder: Decoder[S3BatchEventTask] = 88 | Decoder.forProduct4("taskId", "s3Key", "s3VersionId", "s3BucketArn")(S3BatchEventTask.apply) 89 | 90 | private final case class Impl( 91 | taskId: String, 92 | s3Key: String, 93 | s3VersionId: Option[String], 94 | s3BucketArn: String) 95 | extends S3BatchEventTask { 96 | override def productPrefix = "S3BatchEventTask" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/S3BatchResult.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.Encoder 20 | 21 | // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/s3-batch.d.ts 22 | 23 | sealed abstract class S3BatchResult { 24 | def invocationSchemaVersion: String 25 | def treatMissingKeysAs: S3BatchResultResultCode 26 | def invocationId: String 27 | def results: List[S3BatchResultResult] 28 | } 29 | 30 | object S3BatchResult { 31 | def apply( 32 | invocationSchemaVersion: String, 33 | treatMissingKeysAs: S3BatchResultResultCode, 34 | invocationId: String, 35 | results: List[S3BatchResultResult] 36 | ): S3BatchResult = 37 | new Impl(invocationSchemaVersion, treatMissingKeysAs, invocationId, results) 38 | 39 | implicit val encoder: Encoder[S3BatchResult] = 40 | Encoder.forProduct4( 41 | "invocationSchemaVersion", 42 | "treatMissingKeysAs", 43 | "invocationId", 44 | "results")(r => 45 | (r.invocationSchemaVersion, r.treatMissingKeysAs, r.invocationId, r.results)) 46 | 47 | private final case class Impl( 48 | invocationSchemaVersion: String, 49 | treatMissingKeysAs: S3BatchResultResultCode, 50 | invocationId: String, 51 | results: List[S3BatchResultResult] 52 | ) extends S3BatchResult { 53 | override def productPrefix = "S3BatchResult" 54 | } 55 | } 56 | 57 | sealed abstract class S3BatchResultResultCode 58 | 59 | object S3BatchResultResultCode { 60 | case object Succeeded extends S3BatchResultResultCode 61 | case object TemporaryFailure extends S3BatchResultResultCode 62 | case object PermanentFailure extends S3BatchResultResultCode 63 | 64 | private[events] implicit val encoder: Encoder[S3BatchResultResultCode] = 65 | Encoder.encodeString.contramap { 66 | case Succeeded => "Succeeded" 67 | case TemporaryFailure => "TemporaryFailure" 68 | case PermanentFailure => "PermanentFailure" 69 | } 70 | } 71 | 72 | sealed abstract class S3BatchResultResult { 73 | def taskId: String 74 | def resultCode: S3BatchResultResultCode 75 | def resultString: String 76 | } 77 | 78 | object S3BatchResultResult { 79 | def apply( 80 | taskId: String, 81 | resultCode: S3BatchResultResultCode, 82 | resultString: String 83 | ): S3BatchResultResult = 84 | new Impl(taskId, resultCode, resultString) 85 | 86 | private[events] implicit val encoder: Encoder[S3BatchResultResult] = 87 | Encoder.forProduct3("taskId", "resultCode", "resultString")(r => 88 | (r.taskId, r.resultCode, r.resultString)) 89 | 90 | private final case class Impl( 91 | taskId: String, 92 | resultCode: S3BatchResultResultCode, 93 | resultString: String) 94 | extends S3BatchResultResult { 95 | override def productPrefix = "S3BatchResultResult" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/SnsEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package events 19 | 20 | import cats.syntax.all._ 21 | import io.circe.Decoder 22 | import io.circe.jawn.decode 23 | import io.circe.scodec._ 24 | import scodec.bits.ByteVector 25 | 26 | import java.time.Instant 27 | import java.util.UUID 28 | 29 | sealed abstract class SnsEvent { 30 | def records: List[SnsEventRecord] 31 | } 32 | 33 | object SnsEvent { 34 | def apply(records: List[SnsEventRecord]): SnsEvent = 35 | new Impl(records) 36 | 37 | implicit val decoder: Decoder[SnsEvent] = 38 | Decoder.forProduct1("Records")(SnsEvent.apply) 39 | 40 | private final case class Impl( 41 | records: List[SnsEventRecord] 42 | ) extends SnsEvent { 43 | override def productPrefix = "SnsEvent" 44 | } 45 | } 46 | 47 | sealed abstract class SnsEventRecord { 48 | def eventVersion: String 49 | def eventSubscriptionArn: String 50 | def eventSource: String 51 | def sns: SnsMessage 52 | } 53 | 54 | object SnsEventRecord { 55 | def apply( 56 | eventVersion: String, 57 | eventSubscriptionArn: String, 58 | eventSource: String, 59 | sns: SnsMessage 60 | ): SnsEventRecord = 61 | new Impl(eventVersion, eventSubscriptionArn, eventSource, sns) 62 | 63 | private[events] implicit val decoder: Decoder[SnsEventRecord] = Decoder.forProduct4( 64 | "EventVersion", 65 | "EventSubscriptionArn", 66 | "EventSource", 67 | "Sns" 68 | )(SnsEventRecord.apply) 69 | 70 | private final case class Impl( 71 | eventVersion: String, 72 | eventSubscriptionArn: String, 73 | eventSource: String, 74 | sns: SnsMessage 75 | ) extends SnsEventRecord { 76 | override def productPrefix = "SnsEventRecord" 77 | } 78 | } 79 | 80 | sealed abstract class SnsMessage { 81 | def signature: String 82 | def messageId: UUID 83 | def `type`: String 84 | def topicArn: String 85 | def messageAttributes: Map[String, SnsMessageAttribute] 86 | def signatureVersion: String 87 | def timestamp: Instant 88 | def signingCertUrl: String 89 | def message: String 90 | def unsubscribeUrl: String 91 | def subject: Option[String] 92 | } 93 | 94 | object SnsMessage { 95 | 96 | def apply( 97 | signature: String, 98 | messageId: UUID, 99 | `type`: String, 100 | topicArn: String, 101 | messageAttributes: Map[String, SnsMessageAttribute], 102 | signatureVersion: String, 103 | timestamp: Instant, 104 | signingCertUrl: String, 105 | message: String, 106 | unsubscribeUrl: String, 107 | subject: Option[String] 108 | ): SnsMessage = 109 | new Impl( 110 | signature, 111 | messageId, 112 | `type`, 113 | topicArn, 114 | messageAttributes, 115 | signatureVersion, 116 | timestamp, 117 | signingCertUrl, 118 | message, 119 | unsubscribeUrl, 120 | subject 121 | ) 122 | 123 | private[events] implicit val decoder: Decoder[SnsMessage] = Decoder.forProduct11( 124 | "Signature", 125 | "MessageId", 126 | "Type", 127 | "TopicArn", 128 | "MessageAttributes", 129 | "SignatureVersion", 130 | "Timestamp", 131 | "SigningCertUrl", 132 | "Message", 133 | "UnsubscribeUrl", 134 | "Subject" 135 | )(SnsMessage.apply) 136 | 137 | private final case class Impl( 138 | signature: String, 139 | messageId: UUID, 140 | `type`: String, 141 | topicArn: String, 142 | messageAttributes: Map[String, SnsMessageAttribute], 143 | signatureVersion: String, 144 | timestamp: Instant, 145 | signingCertUrl: String, 146 | message: String, 147 | unsubscribeUrl: String, 148 | subject: Option[String] 149 | ) extends SnsMessage { 150 | override def productPrefix = "SnsMessage" 151 | } 152 | } 153 | 154 | sealed abstract class SnsMessageAttribute 155 | 156 | object SnsMessageAttribute { 157 | final case class String(value: Predef.String) extends SnsMessageAttribute 158 | final case class Binary(value: ByteVector) extends SnsMessageAttribute 159 | final case class Number(value: BigDecimal) extends SnsMessageAttribute 160 | final case class StringArray(value: List[SnsMessageAttributeArrayMember]) 161 | extends SnsMessageAttribute 162 | final case class Unknown( 163 | `type`: Predef.String, 164 | value: Option[Predef.String] 165 | ) extends SnsMessageAttribute 166 | 167 | private[events] implicit val decoder: Decoder[SnsMessageAttribute] = { 168 | val getString: Decoder[Predef.String] = Decoder.instance(_.get[Predef.String]("Value")) 169 | val getByteVector: Decoder[ByteVector] = Decoder.instance(_.get[ByteVector]("Value")) 170 | val getNumber: Decoder[BigDecimal] = Decoder.instance(_.get[BigDecimal]("Value")) 171 | 172 | /* 173 | See https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html 174 | 175 | In particular, for both Number and String.Array: 176 | 177 | > This data type isn't supported for AWS Lambda subscriptions. If you specify this data type for Lambda endpoints, 178 | > it's passed as the String data type in the JSON payload that Amazon SNS delivers to Lambda." 179 | 180 | Circe is smart enough to handle this for BigDecimal, but no such luck for this particular mess. 181 | */ 182 | 183 | val getStringArray: Decoder[List[SnsMessageAttributeArrayMember]] = 184 | getString.emapTry(decode[List[SnsMessageAttributeArrayMember]](_).toTry) 185 | 186 | Decoder.instance(_.get[Predef.String]("Type")).flatMap { 187 | case "String" => getString.map(SnsMessageAttribute.String.apply) 188 | case "Binary" => getByteVector.map(SnsMessageAttribute.Binary.apply) 189 | case "Number" => getNumber.map(SnsMessageAttribute.Number.apply) 190 | case "String.Array" => getStringArray.map(SnsMessageAttribute.StringArray.apply) 191 | case someType => 192 | Decoder.instance(i => 193 | i.get[Option[Predef.String]]("Value").map(SnsMessageAttribute.Unknown(someType, _))) 194 | } 195 | } 196 | } 197 | 198 | sealed abstract class SnsMessageAttributeArrayMember 199 | 200 | object SnsMessageAttributeArrayMember { 201 | final case class String(value: Predef.String) extends SnsMessageAttributeArrayMember 202 | final case class Number(value: BigDecimal) extends SnsMessageAttributeArrayMember 203 | final case class Boolean(value: scala.Boolean) extends SnsMessageAttributeArrayMember 204 | 205 | private[events] implicit val decoder: Decoder[SnsMessageAttributeArrayMember] = { 206 | val bool: Decoder[SnsMessageAttributeArrayMember.Boolean] = 207 | Decoder.decodeBoolean.map(SnsMessageAttributeArrayMember.Boolean.apply) 208 | 209 | val number: Decoder[SnsMessageAttributeArrayMember.Number] = 210 | Decoder.decodeBigDecimal.map(SnsMessageAttributeArrayMember.Number.apply) 211 | 212 | val string: Decoder[SnsMessageAttributeArrayMember.String] = 213 | Decoder.decodeString.map(SnsMessageAttributeArrayMember.String.apply) 214 | 215 | List[Decoder[SnsMessageAttributeArrayMember]]( 216 | bool.widen, 217 | number.widen, 218 | string.widen 219 | ).reduce(_ or _) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/SqsEvent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | package events 19 | 20 | import io.circe.Decoder 21 | import io.circe.scodec._ 22 | import natchez.Kernel 23 | import org.typelevel.ci._ 24 | import scodec.bits.ByteVector 25 | 26 | import java.time.Instant 27 | import scala.util.Try 28 | 29 | // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/sqs.d.ts 30 | // https://docs.aws.amazon.com/lambda/latest/dg/invoking-lambda-function.html#supported-event-source-Sqs 31 | 32 | sealed abstract class SqsEvent { 33 | def records: List[SqsRecord] 34 | } 35 | 36 | object SqsEvent { 37 | def apply(records: List[SqsRecord]): SqsEvent = 38 | new Impl(records) 39 | 40 | implicit val decoder: Decoder[SqsEvent] = 41 | Decoder.instance(_.get[List[SqsRecord]]("Records")).map(SqsEvent(_)) 42 | 43 | private final case class Impl(records: List[SqsRecord]) extends SqsEvent { 44 | override def productPrefix = "SqsEvent" 45 | } 46 | } 47 | 48 | sealed abstract class SqsRecord { 49 | def messageId: String 50 | def receiptHandle: String 51 | def body: String 52 | def attributes: SqsRecordAttributes 53 | def messageAttributes: Map[String, SqsMessageAttribute] 54 | def md5OfBody: String 55 | def eventSource: String 56 | def eventSourceArn: String 57 | def awsRegion: String 58 | } 59 | 60 | object SqsRecord { 61 | def apply( 62 | messageId: String, 63 | receiptHandle: String, 64 | body: String, 65 | attributes: SqsRecordAttributes, 66 | messageAttributes: Map[String, SqsMessageAttribute], 67 | md5OfBody: String, 68 | eventSource: String, 69 | eventSourceArn: String, 70 | awsRegion: String 71 | ): SqsRecord = 72 | new Impl( 73 | messageId, 74 | receiptHandle, 75 | body, 76 | attributes, 77 | messageAttributes, 78 | md5OfBody, 79 | eventSource, 80 | eventSourceArn, 81 | awsRegion 82 | ) 83 | 84 | private[events] implicit val decoder: Decoder[SqsRecord] = Decoder.instance(i => 85 | for { 86 | messageId <- i.get[String]("messageId") 87 | receiptHandle <- i.get[String]("receiptHandle") 88 | body <- i.get[String]("body") 89 | attributes <- i.get[SqsRecordAttributes]("attributes") 90 | messageAttributes <- i.get[Map[String, SqsMessageAttribute]]("messageAttributes") 91 | md5OfBody <- i.get[String]("md5OfBody") 92 | eventSource <- i.get[String]("eventSource") 93 | eventSourceArn <- i.get[String]("eventSourceARN") 94 | awsRegion <- i.get[String]("awsRegion") 95 | } yield SqsRecord( 96 | messageId, 97 | receiptHandle, 98 | body, 99 | attributes, 100 | messageAttributes, 101 | md5OfBody, 102 | eventSource, 103 | eventSourceArn, 104 | awsRegion 105 | )) 106 | 107 | private final case class Impl( 108 | messageId: String, 109 | receiptHandle: String, 110 | body: String, 111 | attributes: SqsRecordAttributes, 112 | messageAttributes: Map[String, SqsMessageAttribute], 113 | md5OfBody: String, 114 | eventSource: String, 115 | eventSourceArn: String, 116 | awsRegion: String 117 | ) extends SqsRecord { 118 | override def productPrefix = "SqsRecord" 119 | } 120 | } 121 | 122 | sealed abstract class SqsRecordAttributes { 123 | def awsTraceHeader: Option[String] 124 | def approximateReceiveCount: String 125 | def sentTimestamp: Instant 126 | def senderId: String 127 | def approximateFirstReceiveTimestamp: Instant 128 | def sequenceNumber: Option[String] 129 | def messageGroupId: Option[String] 130 | def messageDeduplicationId: Option[String] 131 | } 132 | 133 | object SqsRecordAttributes { 134 | 135 | def apply( 136 | awsTraceHeader: Option[String], 137 | approximateReceiveCount: String, 138 | sentTimestamp: Instant, 139 | senderId: String, 140 | approximateFirstReceiveTimestamp: Instant, 141 | sequenceNumber: Option[String], 142 | messageGroupId: Option[String], 143 | messageDeduplicationId: Option[String] 144 | ): SqsRecordAttributes = 145 | new Impl( 146 | awsTraceHeader, 147 | approximateReceiveCount, 148 | sentTimestamp, 149 | senderId, 150 | approximateFirstReceiveTimestamp, 151 | sequenceNumber, 152 | messageGroupId, 153 | messageDeduplicationId 154 | ) 155 | 156 | private[events] implicit val decoder: Decoder[SqsRecordAttributes] = Decoder.instance { i => 157 | import codecs.decodeInstant 158 | for { 159 | awsTraceHeader <- i.get[Option[String]]("AWSTraceHeader") 160 | approximateReceiveCount <- i.get[String]("ApproximateReceiveCount") 161 | sentTimestamp <- i.get[Instant]("SentTimestamp") 162 | senderId <- i.get[String]("SenderId") 163 | approximateFirstReceiveTimestamp <- i.get[Instant]("ApproximateFirstReceiveTimestamp") 164 | sequenceNumber <- i.get[Option[String]]("SequenceNumber") 165 | messageGroupId <- i.get[Option[String]]("MessageGroupId") 166 | messageDeduplicationId <- i.get[Option[String]]("MessageDeduplicationId") 167 | } yield SqsRecordAttributes( 168 | awsTraceHeader, 169 | approximateReceiveCount, 170 | sentTimestamp, 171 | senderId, 172 | approximateFirstReceiveTimestamp, 173 | sequenceNumber, 174 | messageGroupId, 175 | messageDeduplicationId 176 | ) 177 | } 178 | 179 | implicit def kernelSource: KernelSource[SqsRecordAttributes] = a => 180 | Kernel(a.awsTraceHeader.map(`X-Amzn-Trace-Id` -> _).toMap) 181 | 182 | private final case class Impl( 183 | awsTraceHeader: Option[String], 184 | approximateReceiveCount: String, 185 | sentTimestamp: Instant, 186 | senderId: String, 187 | approximateFirstReceiveTimestamp: Instant, 188 | sequenceNumber: Option[String], 189 | messageGroupId: Option[String], 190 | messageDeduplicationId: Option[String] 191 | ) extends SqsRecordAttributes { 192 | override def productPrefix = "SqsRecordAttributes" 193 | } 194 | 195 | private[this] val `X-Amzn-Trace-Id` = ci"X-Amzn-Trace-Id" 196 | } 197 | 198 | sealed abstract class SqsMessageAttribute 199 | object SqsMessageAttribute { 200 | final case class String(value: Predef.String) extends SqsMessageAttribute 201 | 202 | final case class Binary(value: ByteVector) extends SqsMessageAttribute 203 | final case class Number(value: BigDecimal) extends SqsMessageAttribute 204 | final case class Unknown( 205 | stringValue: Option[Predef.String], 206 | binaryValue: Option[Predef.String], 207 | dataType: Predef.String 208 | ) extends SqsMessageAttribute 209 | 210 | private[events] implicit val decoder: Decoder[SqsMessageAttribute] = { 211 | val strValue = 212 | Decoder.instance(_.get[Predef.String]("stringValue")) 213 | 214 | val binValue = 215 | Decoder.instance(_.get[ByteVector]("binaryValue")) 216 | 217 | Decoder.instance(_.get[Predef.String]("dataType")).flatMap { 218 | case "String" => strValue.map(SqsMessageAttribute.String(_): SqsMessageAttribute) 219 | case "Binary" => binValue.map(SqsMessageAttribute.Binary(_)) 220 | case "Number" => 221 | strValue.emapTry(n => Try(BigDecimal(n))).map(SqsMessageAttribute.Number(_)) 222 | case dataType => 223 | Decoder.instance(i => 224 | for { 225 | str <- i.get[Option[Predef.String]]("stringValue") 226 | bin <- i.get[Option[Predef.String]]("binaryValue") 227 | } yield Unknown(str, bin, dataType)) 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/codecs.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import com.comcast.ip4s.Hostname 20 | import com.comcast.ip4s.IpAddress 21 | import io.circe.Decoder 22 | import io.circe.KeyDecoder 23 | import io.circe.KeyEncoder 24 | import org.typelevel.ci.CIString 25 | 26 | import java.time.Instant 27 | import scala.util.Try 28 | 29 | private object codecs { 30 | 31 | implicit def decodeInstant: Decoder[Instant] = 32 | Decoder.decodeBigDecimal.emapTry { millis => 33 | def round(x: BigDecimal) = x.setScale(0, BigDecimal.RoundingMode.DOWN) 34 | Try { 35 | val seconds = round(millis / 1000).toLongExact 36 | val nanos = round((millis % 1000) * 1e6).toLongExact 37 | Instant.ofEpochSecond(seconds, nanos) 38 | } 39 | } 40 | 41 | implicit def decodeIpAddress: Decoder[IpAddress] = 42 | Decoder.decodeString.emap(IpAddress.fromString(_).toRight("Cannot parse IP address")) 43 | 44 | implicit def decodeHostname: Decoder[Hostname] = 45 | Decoder.decodeString.emap(Hostname.fromString(_).toRight("Cannot parse hostname")) 46 | 47 | implicit def decodeKeyCIString: KeyDecoder[CIString] = 48 | KeyDecoder.decodeKeyString.map(CIString(_)) 49 | 50 | implicit def encodeKeyCIString: KeyEncoder[CIString] = 51 | KeyEncoder.encodeKeyString.contramap(_.toString) 52 | 53 | } 54 | -------------------------------------------------------------------------------- /lambda/shared/src/main/scala/feral/lambda/events/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | package object events { 20 | 21 | @deprecated("Renamed to ApiGatewayProxyEvent", "0.3.0") 22 | type APIGatewayProxyRequestEvent = ApiGatewayProxyEvent 23 | @deprecated("Renamed to ApiGatewayProxyEvent", "0.3.0") 24 | val APIGatewayProxyRequestEvent = ApiGatewayProxyEvent 25 | 26 | @deprecated("Renamed to ApiGatewayProxyResult", "0.3.0") 27 | type APIGatewayProxyResponseEvent = ApiGatewayProxyResult 28 | @deprecated("Renamed to ApiGatewayProxyResult", "0.3.0") 29 | val APIGatewayProxyResponseEvent = ApiGatewayProxyResult 30 | 31 | } 32 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/TracedHandlerSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda 18 | 19 | import cats.data.Kleisli 20 | import cats.effect.IO 21 | import feral.lambda.events.KinesisStreamEvent 22 | import natchez.EntryPoint 23 | import natchez.Span 24 | import natchez.Trace 25 | 26 | import scala.annotation.nowarn 27 | 28 | class TracedLambdaSuite { 29 | 30 | @nowarn 31 | def syntaxTest = { // Checking for compilation, nothing more 32 | 33 | implicit def inv: Invocation[IO, KinesisStreamEvent] = ??? 34 | def ioEntryPoint: EntryPoint[IO] = ??? 35 | def needsTrace[F[_]: Trace]: F[Option[INothing]] = ??? 36 | 37 | TracedHandler(ioEntryPoint) { implicit trace => needsTrace[IO] } 38 | 39 | TracedHandler(ioEntryPoint, Kleisli[IO, Span[IO], Option[INothing]](???)) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayProxyEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.literal._ 20 | import munit.FunSuite 21 | 22 | class ApiGatewayProxyEventSuite extends FunSuite { 23 | 24 | import ApiGatewayProxyEventSuite._ 25 | 26 | test("decoder") { 27 | event.as[ApiGatewayProxyEvent].toTry.get 28 | } 29 | 30 | } 31 | 32 | object ApiGatewayProxyEventSuite { 33 | 34 | def event = json""" 35 | { 36 | "body": "eyJ0ZXN0IjoiYm9keSJ9", 37 | "resource": "/{proxy+}", 38 | "path": "/path/to/resource", 39 | "httpMethod": "POST", 40 | "isBase64Encoded": true, 41 | "queryStringParameters": { 42 | "foo": "bar" 43 | }, 44 | "multiValueQueryStringParameters": { 45 | "foo": [ 46 | "bar", 47 | "baz" 48 | ] 49 | }, 50 | "pathParameters": { 51 | "proxy": "/path/to/resource" 52 | }, 53 | "stageVariables": { 54 | "baz": "qux" 55 | }, 56 | "headers": { 57 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 58 | "Accept-Encoding": "gzip, deflate, sdch", 59 | "Accept-Language": "en-US,en;q=0.8", 60 | "Cache-Control": "max-age=0", 61 | "CloudFront-Forwarded-Proto": "https", 62 | "CloudFront-Is-Desktop-Viewer": "true", 63 | "CloudFront-Is-Mobile-Viewer": "false", 64 | "CloudFront-Is-SmartTV-Viewer": "false", 65 | "CloudFront-Is-Tablet-Viewer": "false", 66 | "CloudFront-Viewer-Country": "US", 67 | "Host": "1234567890.execute-api.us-east-1.amazonaws.com", 68 | "Upgrade-Insecure-Requests": "1", 69 | "User-Agent": "Custom User Agent String", 70 | "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", 71 | "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", 72 | "X-Forwarded-For": "127.0.0.1, 127.0.0.2", 73 | "X-Forwarded-Port": "443", 74 | "X-Forwarded-Proto": "https", 75 | "X-MultiHeader": "foo" 76 | }, 77 | "multiValueHeaders": { 78 | "Accept": [ 79 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" 80 | ], 81 | "Accept-Encoding": [ 82 | "gzip, deflate, sdch" 83 | ], 84 | "Accept-Language": [ 85 | "en-US,en;q=0.8" 86 | ], 87 | "Cache-Control": [ 88 | "max-age=0" 89 | ], 90 | "CloudFront-Forwarded-Proto": [ 91 | "https" 92 | ], 93 | "CloudFront-Is-Desktop-Viewer": [ 94 | "true" 95 | ], 96 | "CloudFront-Is-Mobile-Viewer": [ 97 | "false" 98 | ], 99 | "CloudFront-Is-SmartTV-Viewer": [ 100 | "false" 101 | ], 102 | "CloudFront-Is-Tablet-Viewer": [ 103 | "false" 104 | ], 105 | "CloudFront-Viewer-Country": [ 106 | "US" 107 | ], 108 | "Host": [ 109 | "1234567890.execute-api.us-east-1.amazonaws.com" 110 | ], 111 | "Upgrade-Insecure-Requests": [ 112 | "1" 113 | ], 114 | "User-Agent": [ 115 | "Custom User Agent String" 116 | ], 117 | "Via": [ 118 | "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)" 119 | ], 120 | "X-Amz-Cf-Id": [ 121 | "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==" 122 | ], 123 | "X-Forwarded-For": [ 124 | "127.0.0.1, 127.0.0.2" 125 | ], 126 | "X-Forwarded-Port": [ 127 | "443" 128 | ], 129 | "X-Forwarded-Proto": [ 130 | "https" 131 | ], 132 | "X-MultiHeader": [ 133 | "foo", 134 | "bar" 135 | ] 136 | }, 137 | "requestContext": { 138 | "accountId": "123456789012", 139 | "resourceId": "123456", 140 | "stage": "prod", 141 | "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", 142 | "requestTime": "09/Apr/2015:12:34:56 +0000", 143 | "requestTimeEpoch": 1428582896000, 144 | "identity": { 145 | "cognitoIdentityPoolId": null, 146 | "accountId": null, 147 | "cognitoIdentityId": null, 148 | "caller": null, 149 | "accessKey": null, 150 | "sourceIp": "127.0.0.1", 151 | "cognitoAuthenticationType": null, 152 | "cognitoAuthenticationProvider": null, 153 | "userArn": null, 154 | "userAgent": "Custom User Agent String", 155 | "user": null 156 | }, 157 | "path": "/prod/path/to/resource", 158 | "resourcePath": "/{proxy+}", 159 | "httpMethod": "POST", 160 | "apiId": "1234567890", 161 | "protocol": "HTTP/1.1" 162 | } 163 | } 164 | """ 165 | 166 | } 167 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayProxyEventV2Suite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.literal._ 20 | import munit.FunSuite 21 | 22 | class ApiGatewayProxyEventV2Suite extends FunSuite { 23 | 24 | import ApiGatewayProxyEventV2Suite._ 25 | 26 | test("decoder") { 27 | event.as[ApiGatewayProxyEventV2].toTry.get 28 | eventNoCookies.as[ApiGatewayProxyEventV2].toTry.get 29 | } 30 | 31 | } 32 | 33 | object ApiGatewayProxyEventV2Suite { 34 | 35 | def event = json""" 36 | { 37 | "version": "2.0", 38 | "routeKey": "ANY /nodejs-apig-function-1G3XMPLZXVXYI", 39 | "rawPath": "/default/nodejs-apig-function-1G3XMPLZXVXYI", 40 | "rawQueryString": "", 41 | "cookies": [ 42 | "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2", 43 | "regStatus=pre-register" 44 | ], 45 | "headers": { 46 | "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", 47 | "accept-encoding": "gzip, deflate, br", 48 | "accept-language": "en-US,en;q=0.9", 49 | "content-length": "0", 50 | "host": "r3pmxmplak.execute-api.us-east-2.amazonaws.com", 51 | "sec-fetch-dest": "document", 52 | "sec-fetch-mode": "navigate", 53 | "sec-fetch-site": "cross-site", 54 | "sec-fetch-user": "?1", 55 | "upgrade-insecure-requests": "1", 56 | "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", 57 | "x-amzn-trace-id": "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e", 58 | "x-forwarded-for": "205.255.255.176", 59 | "x-forwarded-port": "443", 60 | "x-forwarded-proto": "https" 61 | }, 62 | "requestContext": { 63 | "accountId": "123456789012", 64 | "apiId": "r3pmxmplak", 65 | "domainName": "r3pmxmplak.execute-api.us-east-2.amazonaws.com", 66 | "domainPrefix": "r3pmxmplak", 67 | "http": { 68 | "method": "GET", 69 | "path": "/default/nodejs-apig-function-1G3XMPLZXVXYI", 70 | "protocol": "HTTP/1.1", 71 | "sourceIp": "205.255.255.176", 72 | "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36" 73 | }, 74 | "requestId": "JKJaXmPLvHcESHA=", 75 | "routeKey": "ANY /nodejs-apig-function-1G3XMPLZXVXYI", 76 | "stage": "default", 77 | "time": "10/Mar/2020:05:16:23 +0000", 78 | "timeEpoch": 1583817383220 79 | }, 80 | "isBase64Encoded": true 81 | } 82 | """ 83 | 84 | def eventNoCookies = json""" 85 | { 86 | "version": "2.0", 87 | "routeKey": "ANY /nodejs-apig-function", 88 | "rawPath": "/default/nodejs-apig-function", 89 | "rawQueryString": "", 90 | "headers": { 91 | "accept": "*/*", 92 | "content-length": "0", 93 | "host": "r3pmxmplak.execute-api.us-east-2.amazonaws.com", 94 | "user-agent": "curl/7.64.1", 95 | "x-amzn-trace-id": "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e", 96 | "x-forwarded-for": "205.255.255.176", 97 | "x-forwarded-port": "443", 98 | "x-forwarded-proto": "https" 99 | }, 100 | "requestContext": { 101 | "accountId": "123456789012", 102 | "apiId": "r3pmxmplak", 103 | "domainName": "r3pmxmplak.execute-api.us-east-2.amazonaws.com", 104 | "domainPrefix": "r3pmxmplak", 105 | "http": { 106 | "method": "GET", 107 | "path": "/default/nodejs-apig-function", 108 | "protocol": "HTTP/1.1", 109 | "sourceIp": "205.255.255.176", 110 | "userAgent": "curl/7.64.1" 111 | }, 112 | "requestId": "JKJaXmPLvHcESHA", 113 | "routeKey": "ANY /nodejs-apig-function", 114 | "stage": "default", 115 | "time": "15/Mar/2022:15:07:35 +0000", 116 | "timeEpoch": 1647356855012 117 | }, 118 | "isBase64Encoded": false 119 | } 120 | """ 121 | 122 | } 123 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayV2WebSocketEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.literal._ 20 | import munit.FunSuite 21 | 22 | class ApiGatewayV2WebSocketEventSuite extends FunSuite { 23 | 24 | import ApiGatewayV2WebSocketEventSuite._ 25 | 26 | test("decode connect") { 27 | connectEvent.as[ApiGatewayV2WebSocketEvent].toTry.get 28 | } 29 | 30 | test("decode disconnect") { 31 | disconnectEvent.as[ApiGatewayV2WebSocketEvent].toTry.get 32 | } 33 | 34 | } 35 | 36 | object ApiGatewayV2WebSocketEventSuite { 37 | 38 | def connectEvent = json""" 39 | { 40 | "headers": { 41 | "Host": "abcd123.execute-api.us-east-1.amazonaws.com", 42 | "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits", 43 | "Sec-WebSocket-Key": "...", 44 | "Sec-WebSocket-Version": "13", 45 | "X-Amzn-Trace-Id": "...", 46 | "X-Forwarded-For": "192.0.2.1", 47 | "X-Forwarded-Port": "443", 48 | "X-Forwarded-Proto": "https" 49 | }, 50 | "multiValueHeaders": { 51 | "Host": [ 52 | "abcd123.execute-api.us-east-1.amazonaws.com" 53 | ], 54 | "Sec-WebSocket-Extensions": [ 55 | "permessage-deflate; client_max_window_bits" 56 | ], 57 | "Sec-WebSocket-Key": [ 58 | "..." 59 | ], 60 | "Sec-WebSocket-Version": [ 61 | "13" 62 | ], 63 | "X-Amzn-Trace-Id": [ 64 | "..." 65 | ], 66 | "X-Forwarded-For": [ 67 | "192.0.2.1" 68 | ], 69 | "X-Forwarded-Port": [ 70 | "443" 71 | ], 72 | "X-Forwarded-Proto": [ 73 | "https" 74 | ] 75 | }, 76 | "requestContext": { 77 | "routeKey": "$$connect", 78 | "eventType": "CONNECT", 79 | "extendedRequestId": "ABCD1234=", 80 | "requestTime": "09/Feb/2024:18:11:43 +0000", 81 | "messageDirection": "IN", 82 | "stage": "prod", 83 | "connectedAt": 1707502303419, 84 | "requestTimeEpoch": 1707502303420, 85 | "identity": { 86 | "sourceIp": "192.0.2.1" 87 | }, 88 | "requestId": "ABCD1234=", 89 | "domainName": "abcd1234.execute-api.us-east-1.amazonaws.com", 90 | "connectionId": "AAAA1234=", 91 | "apiId": "abcd1234" 92 | }, 93 | "isBase64Encoded": false 94 | } 95 | """ 96 | def disconnectEvent = json""" 97 | { 98 | "headers": { 99 | "Host": "abcd1234.execute-api.us-east-1.amazonaws.com", 100 | "x-api-key": "", 101 | "X-Forwarded-For": "", 102 | "x-restapi": "" 103 | }, 104 | "multiValueHeaders": { 105 | "Host": [ 106 | "abcd1234.execute-api.us-east-1.amazonaws.com" 107 | ], 108 | "x-api-key": [ 109 | "" 110 | ], 111 | "X-Forwarded-For": [ 112 | "" 113 | ], 114 | "x-restapi": [ 115 | "" 116 | ] 117 | }, 118 | "requestContext": { 119 | "routeKey": "$$disconnect", 120 | "disconnectStatusCode": 1005, 121 | "eventType": "DISCONNECT", 122 | "extendedRequestId": "ABCD1234=", 123 | "requestTime": "09/Feb/2024:18:23:28 +0000", 124 | "messageDirection": "IN", 125 | "disconnectReason": "Client-side close frame status not set", 126 | "stage": "prod", 127 | "connectedAt": 1707503007396, 128 | "requestTimeEpoch": 1707503008941, 129 | "identity": { 130 | "sourceIp": "192.0.2.1" 131 | }, 132 | "requestId": "ABCD1234=", 133 | "domainName": "abcd1234.execute-api.us-east-1.amazonaws.com", 134 | "connectionId": "AAAA1234=", 135 | "apiId": "abcd1234" 136 | }, 137 | "isBase64Encoded": false 138 | } 139 | """ 140 | } 141 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/DynamoDbStreamEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import cats.syntax.option._ 20 | import io.circe.literal._ 21 | import munit.FunSuite 22 | import scodec.bits._ 23 | 24 | class DynamoDbStreamEventSuite extends FunSuite { 25 | 26 | test("decoder") { 27 | event.as[DynamoDbStreamEvent].toTry.get 28 | } 29 | 30 | test("AttributeValue should decode a B") { 31 | 32 | val vector = utf8Bytes"foo" 33 | 34 | val source = json""" 35 | { 36 | "B": ${vector.toBase64} 37 | } 38 | """ 39 | 40 | val decoded = source.as[AttributeValue].toOption.flatMap(_.b) 41 | 42 | assertEquals(decoded, vector.some) 43 | } 44 | 45 | test("AttributeValue should decode a BS") { 46 | 47 | val vector = utf8Bytes"foo" 48 | 49 | val source = json""" 50 | { 51 | "BS": [ 52 | ${vector.toBase64} 53 | ] 54 | } 55 | """ 56 | 57 | val decoded = source.as[AttributeValue].toOption.flatMap(_.bs) 58 | 59 | assertEquals(decoded, List(vector).some) 60 | } 61 | 62 | def event = json""" 63 | { 64 | "Records": [ 65 | { 66 | "eventID": "1", 67 | "eventVersion": "1.0", 68 | "dynamodb": { 69 | "Keys": { 70 | "Id": { 71 | "N": "101" 72 | } 73 | }, 74 | "NewImage": { 75 | "Message": { 76 | "S": "New item!" 77 | }, 78 | "Id": { 79 | "N": "101" 80 | }, 81 | "Image": { 82 | "B": "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII" 83 | }, 84 | "Images": { 85 | "BS": [ 86 | "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC", 87 | "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC", 88 | "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC" 89 | ] 90 | } 91 | }, 92 | "StreamViewType": "NEW_AND_OLD_IMAGES", 93 | "SequenceNumber": "111", 94 | "SizeBytes": 26 95 | }, 96 | "awsRegion": "us-west-2", 97 | "eventName": "INSERT", 98 | "eventSourceARN": "arn:aws:dynamodb:us-west-2:account-id:table/ExampleTableWithStream/stream/2015-06-27T00:48:05.899", 99 | "eventSource": "aws:dynamodb" 100 | }, 101 | { 102 | "eventID": "2", 103 | "eventVersion": "1.0", 104 | "dynamodb": { 105 | "OldImage": { 106 | "Message": { 107 | "S": "New item!" 108 | }, 109 | "Id": { 110 | "N": "101" 111 | } 112 | }, 113 | "SequenceNumber": "222", 114 | "Keys": { 115 | "Id": { 116 | "N": "101" 117 | } 118 | }, 119 | "SizeBytes": 59, 120 | "NewImage": { 121 | "Message": { 122 | "S": "This item has changed" 123 | }, 124 | "Id": { 125 | "N": "101" 126 | } 127 | }, 128 | "StreamViewType": "NEW_AND_OLD_IMAGES" 129 | }, 130 | "awsRegion": "us-west-2", 131 | "eventName": "MODIFY", 132 | "eventSourceARN": "arn:aws:dynamodb:us-west-2:account-id:table/ExampleTableWithStream/stream/2015-06-27T00:48:05.899", 133 | "eventSource": "aws:dynamodb" 134 | }, 135 | { 136 | "eventID": "3", 137 | "eventVersion": "1.0", 138 | "dynamodb": { 139 | "Keys": { 140 | "Id": { 141 | "N": "101" 142 | } 143 | }, 144 | "SizeBytes": 38, 145 | "SequenceNumber": "333", 146 | "OldImage": { 147 | "Message": { 148 | "S": "This item has changed" 149 | }, 150 | "Id": { 151 | "N": "101" 152 | } 153 | }, 154 | "StreamViewType": "NEW_AND_OLD_IMAGES" 155 | }, 156 | "awsRegion": "us-west-2", 157 | "eventName": "REMOVE", 158 | "eventSourceARN": "arn:aws:dynamodb:us-west-2:account-id:table/ExampleTableWithStream/stream/2015-06-27T00:48:05.899", 159 | "eventSource": "aws:dynamodb" 160 | } 161 | ] 162 | } 163 | """ 164 | 165 | } 166 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/InstantDecoderSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.Json 20 | import munit.ScalaCheckSuite 21 | import org.scalacheck.Arbitrary 22 | import org.scalacheck.Gen 23 | import org.scalacheck.Prop.forAll 24 | 25 | import java.time.Instant 26 | 27 | class InstantDecoderSuite extends ScalaCheckSuite { 28 | 29 | import codecs.decodeInstant 30 | 31 | implicit val arbitraryInstant: Arbitrary[Instant] = Arbitrary( 32 | Gen.long.map(Instant.ofEpochMilli(_))) 33 | 34 | property("round-trip") { 35 | forAll { (instant: Instant) => 36 | val decoded = Json.fromLong(instant.toEpochMilli()).as[Instant].toTry.get 37 | assertEquals(decoded, instant) 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/KafkaEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import com.comcast.ip4s.SocketAddress 20 | import feral.lambda.events.KafkaEvent.TopicPartition 21 | import feral.lambda.events.KafkaRecord.TimestampType.CreateTime 22 | import io.circe.literal._ 23 | import munit.FunSuite 24 | import scodec.bits.ByteVector 25 | 26 | import java.time.Instant 27 | 28 | class KafkaEventSuite extends FunSuite { 29 | 30 | test("decoderMSKEvent") { 31 | assertEquals(mskSampleEvent.as[MskEvent].toTry.get, MSKResult) 32 | } 33 | 34 | test("decoderSelfManageKafkaEvent") { 35 | assertEquals(selfManagedKafkaEvent.as[KafkaEvent].toTry.get, selfManagedKafkaResult) 36 | } 37 | 38 | test("mskEventDecodesAsKafkaEvent") { 39 | assertEquals(mskSampleEvent.as[KafkaEvent].toTry.get, selfManagedKafkaResult) 40 | } 41 | 42 | test("topicPartitionDecoder") { 43 | assertEquals( 44 | topicPartitionSample.as[Map[TopicPartition, Int]].toTry.get, 45 | topicPartitionResult) 46 | } 47 | 48 | def topicPartitionSample = json"""{"my-topic-0":0}""" 49 | 50 | def topicPartitionResult: Map[TopicPartition, Int] = Map(TopicPartition("my-topic", 0) -> 0) 51 | 52 | def selfManagedKafkaEvent = json""" 53 | { 54 | "eventSource": "SelfManagedKafka", 55 | "bootstrapServers":"b-2.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092,b-1.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092", 56 | "records":{ 57 | "mytopic-0":[ 58 | { 59 | "topic":"mytopic", 60 | "partition":0, 61 | "offset":15, 62 | "timestamp":1545084650987, 63 | "timestampType":"CREATE_TIME", 64 | "key":"abcDEFghiJKLmnoPQRstuVWXyz1234==", 65 | "value":"SGVsbG8sIHRoaXMgaXMgYSB0ZXN0Lg==", 66 | "headers":[ 67 | { 68 | "headerKey":[ 69 | 104, 70 | 101, 71 | 97, 72 | 100, 73 | 101, 74 | 114, 75 | 86, 76 | 97, 77 | 108, 78 | 117, 79 | 101 80 | ] 81 | } 82 | ] 83 | } 84 | ] 85 | } 86 | }""" 87 | 88 | def selfManagedKafkaResult: KafkaEvent = KafkaEvent( 89 | records = Map( 90 | TopicPartition("mytopic", 0) -> List(KafkaRecord( 91 | topic = "mytopic", 92 | partition = 0, 93 | offset = 15, 94 | timestamp = Instant.ofEpochMilli(1545084650987L), 95 | timestampType = CreateTime, 96 | headers = 97 | List(("headerKey", ByteVector(104, 101, 97, 100, 101, 114, 86, 97, 108, 117, 101))), 98 | key = ByteVector.fromBase64("abcDEFghiJKLmnoPQRstuVWXyz1234==").get, 99 | value = ByteVector.fromBase64("SGVsbG8sIHRoaXMgaXMgYSB0ZXN0Lg==").get 100 | ))), 101 | bootstrapServers = List( 102 | SocketAddress 103 | .fromString("b-2.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092") 104 | .get, 105 | SocketAddress 106 | .fromString("b-1.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092") 107 | .get 108 | ) 109 | ) 110 | 111 | def mskSampleEvent = 112 | json""" 113 | { 114 | "eventSource":"aws:kafka", 115 | "eventSourceArn":"arn:aws:kafka:us-east-1:123456789012:cluster/vpc-2priv-2pub/751d2973-a626-431c-9d4e-d7975eb44dd7-2", 116 | "bootstrapServers":"b-2.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092,b-1.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092", 117 | "records":{ 118 | "mytopic-0":[ 119 | { 120 | "topic":"mytopic", 121 | "partition":0, 122 | "offset":15, 123 | "timestamp":1545084650987, 124 | "timestampType":"CREATE_TIME", 125 | "key":"abcDEFghiJKLmnoPQRstuVWXyz1234==", 126 | "value":"SGVsbG8sIHRoaXMgaXMgYSB0ZXN0Lg==", 127 | "headers":[ 128 | { 129 | "headerKey":[ 130 | 104, 131 | 101, 132 | 97, 133 | 100, 134 | 101, 135 | 114, 136 | 86, 137 | 97, 138 | 108, 139 | 117, 140 | 101 141 | ] 142 | } 143 | ] 144 | } 145 | ] 146 | } 147 | } 148 | """ 149 | 150 | def MSKResult: MskEvent = MskEvent( 151 | records = Map( 152 | TopicPartition("mytopic", 0) -> List(KafkaRecord( 153 | topic = "mytopic", 154 | partition = 0, 155 | offset = 15, 156 | timestamp = Instant.ofEpochMilli(1545084650987L), 157 | timestampType = CreateTime, 158 | headers = 159 | List(("headerKey", ByteVector(104, 101, 97, 100, 101, 114, 86, 97, 108, 117, 101))), 160 | key = ByteVector.fromBase64("abcDEFghiJKLmnoPQRstuVWXyz1234==").get, 161 | value = ByteVector.fromBase64("SGVsbG8sIHRoaXMgaXMgYSB0ZXN0Lg==").get 162 | ))), 163 | eventSourceArn = 164 | "arn:aws:kafka:us-east-1:123456789012:cluster/vpc-2priv-2pub/751d2973-a626-431c-9d4e-d7975eb44dd7-2", 165 | bootstrapServers = List( 166 | SocketAddress 167 | .fromString("b-2.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092") 168 | .get, 169 | SocketAddress 170 | .fromString("b-1.demo-cluster-1.a1bcde.c1.kafka.us-east-1.amazonaws.com:9092") 171 | .get 172 | ) 173 | ) 174 | } 175 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/KinesisStreamEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.literal._ 20 | import munit.FunSuite 21 | 22 | @deprecated( 23 | "Moved to kinesis4cats. See https://etspaceman.github.io/kinesis4cats/feral/getting-started.html.", 24 | since = "0.3.0") 25 | class KinesisStreamEventSuite extends FunSuite { 26 | 27 | test("decoder") { 28 | event.as[KinesisStreamEvent].toTry.get 29 | } 30 | 31 | def event = json""" 32 | { 33 | "Records": [ 34 | { 35 | "kinesis": { 36 | "kinesisSchemaVersion": "1.0", 37 | "partitionKey": "1", 38 | "sequenceNumber": "49590338271490256608559692538361571095921575989136588898", 39 | "data": "SGVsbG8sIHRoaXMgaXMgYSB0ZXN0Lg==", 40 | "approximateArrivalTimestamp": 1545084650.987 41 | }, 42 | "eventSource": "aws:kinesis", 43 | "eventVersion": "1.0", 44 | "eventID": "shardId-000000000006:49590338271490256608559692538361571095921575989136588898", 45 | "eventName": "aws:kinesis:record", 46 | "invokeIdentityArn": "arn:aws:iam::123456789012:role/lambda-kinesis-role", 47 | "awsRegion": "us-east-2", 48 | "eventSourceARN": "arn:aws:kinesis:us-east-2:123456789012:stream/lambda-stream" 49 | } 50 | ] 51 | } 52 | """ 53 | 54 | } 55 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/S3BatchEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.literal._ 20 | import io.circe.syntax._ 21 | import munit.FunSuite 22 | 23 | class S3BatchEventSuite extends FunSuite { 24 | 25 | test("decoder") { 26 | event.as[S3BatchEvent].toTry.get 27 | eventNullVersionId.as[S3BatchEvent].toTry.get 28 | } 29 | 30 | test("result encoder") { 31 | assertEquals(result.asJson, resultEncoded) 32 | } 33 | 34 | def event = json""" 35 | { 36 | "invocationSchemaVersion": "1.0", 37 | "invocationId": "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo", 38 | "job": { 39 | "id": "f3cc4f60-61f6-4a2b-8a21-d07600c373ce" 40 | }, 41 | "tasks": [ 42 | { 43 | "taskId": "dGFza2lkZ29lc2hlcmUK", 44 | "s3Key": "customerImage1.jpg", 45 | "s3VersionId": "1", 46 | "s3BucketArn": "arn:aws:s3:us-east-1:0123456788:awsexamplebucket1" 47 | } 48 | ] 49 | } 50 | """ 51 | 52 | def eventNullVersionId = json""" 53 | { 54 | "invocationSchemaVersion": "1.0", 55 | "invocationId": "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo", 56 | "job": { 57 | "id": "f3cc4f60-61f6-4a2b-8a21-d07600c373ce" 58 | }, 59 | "tasks": [ 60 | { 61 | "taskId": "dGFza2lkZ29lc2hlcmUK", 62 | "s3Key": "customerImage1.jpg", 63 | "s3VersionId": null, 64 | "s3BucketArn": "arn:aws:s3:us-east-1:0123456788:awsexamplebucket1" 65 | } 66 | ] 67 | } 68 | """ 69 | 70 | def result = S3BatchResult( 71 | "1.0", 72 | S3BatchResultResultCode.PermanentFailure, 73 | "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo", 74 | List( 75 | S3BatchResultResult( 76 | "dGFza2lkZ29lc2hlcmUK", 77 | S3BatchResultResultCode.Succeeded, 78 | List("Mary Major", "John Stiles").asJson.noSpaces)) 79 | ) 80 | 81 | def resultEncoded = json""" 82 | { 83 | "invocationSchemaVersion": "1.0", 84 | "treatMissingKeysAs" : "PermanentFailure", 85 | "invocationId" : "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo", 86 | "results": [ 87 | { 88 | "taskId": "dGFza2lkZ29lc2hlcmUK", 89 | "resultCode": "Succeeded", 90 | "resultString": "[\"Mary Major\",\"John Stiles\"]" 91 | } 92 | ] 93 | } 94 | """ 95 | } 96 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/S3EventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import com.comcast.ip4s._ 20 | import io.circe.literal._ 21 | import munit.FunSuite 22 | 23 | import java.time.Instant 24 | 25 | class S3EventSuite extends FunSuite { 26 | 27 | test("decoder") { 28 | assertEquals(event.as[S3Event].toTry.get, result) 29 | } 30 | 31 | def event = json""" 32 | { 33 | "Records":[ 34 | { 35 | "eventVersion":"2.1", 36 | "eventSource":"aws:s3", 37 | "awsRegion":"us-west-2", 38 | "eventTime":"1970-01-01T00:00:00.000Z", 39 | "eventName":"ObjectCreated:Put", 40 | "userIdentity":{ 41 | "principalId":"AIDAJDPLRKLG7UEXAMPLE" 42 | }, 43 | "requestParameters":{ 44 | "sourceIPAddress":"127.0.0.1" 45 | }, 46 | "responseElements":{ 47 | "x-amz-request-id":"C3D13FE58DE4C810", 48 | "x-amz-id-2":"FMyUVURIY8/IgAtTv8xRjskZQpcIZ9KG4V5Wp6S7S/JRWeUWerMUE5JgHvANOjpD" 49 | }, 50 | "s3":{ 51 | "s3SchemaVersion":"1.0", 52 | "configurationId":"testConfigRule", 53 | "bucket":{ 54 | "name":"mybucket", 55 | "ownerIdentity":{ 56 | "principalId":"A3NL1KOZZKExample" 57 | }, 58 | "arn":"arn:aws:s3:::mybucket" 59 | }, 60 | "object":{ 61 | "key":"HappyFace.jpg", 62 | "size":1024, 63 | "eTag":"d41d8cd98f00b204e9800998ecf8427e", 64 | "versionId":"096fKKXTRTtl3on89fVO.nfljtsv6qko", 65 | "sequencer":"0055AED6DCD90281E5" 66 | } 67 | } 68 | } 69 | ] 70 | } 71 | """ 72 | 73 | def result = S3Event(records = List( 74 | S3EventRecord( 75 | eventVersion = "2.1", 76 | eventSource = "aws:s3", 77 | awsRegion = "us-west-2", 78 | eventTime = Instant.ofEpochSecond(0), 79 | eventName = "ObjectCreated:Put", 80 | userIdentity = S3UserIdentity("AIDAJDPLRKLG7UEXAMPLE"), 81 | requestParameters = S3RequestParameters(ip"127.0.0.1"), 82 | responseElements = S3ResponseElements( 83 | "C3D13FE58DE4C810", 84 | "FMyUVURIY8/IgAtTv8xRjskZQpcIZ9KG4V5Wp6S7S/JRWeUWerMUE5JgHvANOjpD" 85 | ), 86 | s3 = S3( 87 | s3SchemaVersion = "1.0", 88 | configurationId = "testConfigRule": String, 89 | bucket = S3Bucket( 90 | name = "mybucket", 91 | ownerIdentity = S3UserIdentity("A3NL1KOZZKExample"), 92 | arn = "arn:aws:s3:::mybucket" 93 | ), 94 | `object` = S3Object( 95 | key = "HappyFace.jpg", 96 | size = 1024L, 97 | eTag = "d41d8cd98f00b204e9800998ecf8427e", 98 | versionId = Option("096fKKXTRTtl3on89fVO.nfljtsv6qko"), 99 | sequencer = "0055AED6DCD90281E5" 100 | ) 101 | ), 102 | glacierEventData = None 103 | ) 104 | )) 105 | 106 | } 107 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/SnsEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.Json 20 | import io.circe.literal._ 21 | import munit.FunSuite 22 | import scodec.bits.ByteVector 23 | 24 | import java.time.Instant 25 | import java.util.UUID 26 | 27 | class SnsEventSuite extends FunSuite { 28 | import SnsEventSuite._ 29 | 30 | test("decoder") { 31 | assertEquals(event.as[SnsEvent].toTry.get, decoded) 32 | assertEquals(noSubject.as[SnsEvent].toTry.get, decodedNoSubject) 33 | } 34 | } 35 | 36 | object SnsEventSuite { 37 | val decoded: SnsEvent = 38 | SnsEvent( 39 | List( 40 | SnsEventRecord( 41 | "1.0", 42 | "arn:aws:sns:TEST", 43 | "aws:sns", 44 | SnsMessage( 45 | "TEST", 46 | UUID.fromString("9f0cfa95-e344-4f93-89ab-5e979503ed1f"), 47 | "Notification", 48 | "arn:aws:sns:TEST", 49 | Map( 50 | "number" -> SnsMessageAttribute.Number(1e+6), 51 | "binary" -> SnsMessageAttribute.Binary(ByteVector.fromBase64("VGVzdGluZwo=").get), 52 | "string" -> SnsMessageAttribute.String("Testing"), 53 | "numberString" -> SnsMessageAttribute.Number(5), 54 | "array" -> SnsMessageAttribute.StringArray(List( 55 | SnsMessageAttributeArrayMember.Number(1), 56 | SnsMessageAttributeArrayMember.String("two"), 57 | SnsMessageAttributeArrayMember.Boolean(true), 58 | SnsMessageAttributeArrayMember.Boolean(false) 59 | )), 60 | "unsupported" -> SnsMessageAttribute 61 | .Unknown("FancyNewType", Some("SpecialValueHere")) 62 | ), 63 | "1", 64 | Instant.parse("2022-04-06T01:02:03.456Z"), 65 | "TEST", 66 | "Testing Message", 67 | "TEST", 68 | Some("Test Message") 69 | ) 70 | ) 71 | ) 72 | ) 73 | 74 | val decodedNoSubject: SnsEvent = 75 | SnsEvent( 76 | List( 77 | SnsEventRecord( 78 | "1.0", 79 | "arn:aws:sns:TEST", 80 | "aws:sns", 81 | SnsMessage( 82 | "TEST", 83 | UUID.fromString("9f0cfa95-e344-4f93-89ab-5e979503ed1f"), 84 | "Notification", 85 | "arn:aws:sns:TEST", 86 | Map( 87 | "number" -> SnsMessageAttribute.Number(1e+6), 88 | "binary" -> SnsMessageAttribute.Binary(ByteVector.fromBase64("VGVzdGluZwo=").get), 89 | "string" -> SnsMessageAttribute.String("Testing"), 90 | "numberString" -> SnsMessageAttribute.Number(5), 91 | "array" -> SnsMessageAttribute.StringArray(List( 92 | SnsMessageAttributeArrayMember.Number(1), 93 | SnsMessageAttributeArrayMember.String("two"), 94 | SnsMessageAttributeArrayMember.Boolean(true), 95 | SnsMessageAttributeArrayMember.Boolean(false) 96 | )), 97 | "unsupported" -> SnsMessageAttribute 98 | .Unknown("FancyNewType", Some("SpecialValueHere")) 99 | ), 100 | "1", 101 | Instant.parse("2022-04-06T01:02:03.456Z"), 102 | "TEST", 103 | "Testing Message", 104 | "TEST", 105 | None 106 | ) 107 | ) 108 | ) 109 | ) 110 | 111 | val event: Json = json""" 112 | { 113 | "Records": [ 114 | { 115 | "EventVersion": "1.0", 116 | "EventSubscriptionArn": "arn:aws:sns:TEST", 117 | "EventSource": "aws:sns", 118 | "Sns": { 119 | "Signature": "TEST", 120 | "MessageId": "9f0cfa95-e344-4f93-89ab-5e979503ed1f", 121 | "Type": "Notification", 122 | "TopicArn": "arn:aws:sns:TEST", 123 | "MessageAttributes": { 124 | "string": { 125 | "Type": "String", 126 | "Value": "Testing" 127 | }, 128 | "binary": { 129 | "Type": "Binary", 130 | "Value": "VGVzdGluZwo=" 131 | }, 132 | "number": { 133 | "Type": "Number", 134 | "Value": 1e6 135 | }, 136 | "numberString": { 137 | "Type": "Number", 138 | "Value": "5" 139 | }, 140 | "array": { 141 | "Type": "String.Array", 142 | "Value": "[1, \"two\", true, false]" 143 | }, 144 | "unsupported": { 145 | "Type": "FancyNewType", 146 | "Value": "SpecialValueHere" 147 | } 148 | }, 149 | "SignatureVersion": "1", 150 | "Timestamp": "2022-04-06T01:02:03.456Z", 151 | "SigningCertUrl": "TEST", 152 | "Message": "Testing Message", 153 | "UnsubscribeUrl": "TEST", 154 | "Subject": "Test Message" 155 | } 156 | } 157 | ] 158 | } 159 | """ 160 | 161 | val noSubject: Json = json""" 162 | { 163 | "Records": [ 164 | { 165 | "EventVersion": "1.0", 166 | "EventSubscriptionArn": "arn:aws:sns:TEST", 167 | "EventSource": "aws:sns", 168 | "Sns": { 169 | "Signature": "TEST", 170 | "MessageId": "9f0cfa95-e344-4f93-89ab-5e979503ed1f", 171 | "Type": "Notification", 172 | "TopicArn": "arn:aws:sns:TEST", 173 | "MessageAttributes": { 174 | "string": { 175 | "Type": "String", 176 | "Value": "Testing" 177 | }, 178 | "binary": { 179 | "Type": "Binary", 180 | "Value": "VGVzdGluZwo=" 181 | }, 182 | "number": { 183 | "Type": "Number", 184 | "Value": 1e6 185 | }, 186 | "numberString": { 187 | "Type": "Number", 188 | "Value": "5" 189 | }, 190 | "array": { 191 | "Type": "String.Array", 192 | "Value": "[1, \"two\", true, false]" 193 | }, 194 | "unsupported": { 195 | "Type": "FancyNewType", 196 | "Value": "SpecialValueHere" 197 | } 198 | }, 199 | "SignatureVersion": "1", 200 | "Timestamp": "2022-04-06T01:02:03.456Z", 201 | "SigningCertUrl": "TEST", 202 | "Message": "Testing Message", 203 | "UnsubscribeUrl": "TEST" 204 | } 205 | } 206 | ] 207 | } 208 | """ 209 | } 210 | -------------------------------------------------------------------------------- /lambda/shared/src/test/scala/feral/lambda/events/SqsEventSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.events 18 | 19 | import io.circe.literal._ 20 | import munit.FunSuite 21 | 22 | class SqsEventSuite extends FunSuite { 23 | 24 | test("decoder") { 25 | event.as[SqsEvent].toTry.get 26 | } 27 | 28 | def event = json""" 29 | { 30 | "Records": [ 31 | { 32 | "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", 33 | "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", 34 | "body": "test", 35 | "attributes": { 36 | "ApproximateReceiveCount": "1", 37 | "SentTimestamp": "1545082649183", 38 | "SenderId": "AIDAIENQZJOLO23YVJ4VO", 39 | "ApproximateFirstReceiveTimestamp": "1545082649185" 40 | }, 41 | "messageAttributes": { 42 | }, 43 | "md5OfBody": "098f6bcd4621d373cade4e832627b4f6", 44 | "eventSource": "aws:sqs", 45 | "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", 46 | "awsRegion": "us-east-2" 47 | } 48 | ] 49 | } 50 | """ 51 | 52 | } 53 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | val sbtlTlV = "0.8.0" 2 | addSbtPlugin("org.typelevel" % "sbt-typelevel" % sbtlTlV) 3 | addSbtPlugin("org.typelevel" % "sbt-typelevel-scalafix" % sbtlTlV) 4 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") 5 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") 6 | -------------------------------------------------------------------------------- /sbt-lambda/src/main/scala/feral/lambda/sbt/LambdaJSPlugin.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.lambda.sbt 18 | 19 | import io.chrisdavenport.npmpackage.sbtplugin.NpmPackagePlugin 20 | import io.chrisdavenport.npmpackage.sbtplugin.NpmPackagePlugin.autoImport._ 21 | import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._ 22 | import org.scalajs.sbtplugin.ScalaJSPlugin 23 | import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ 24 | import org.scalajs.sbtplugin.Stage 25 | import sbt.AutoPlugin 26 | import sbt.Keys._ 27 | import sbt.PluginTrigger 28 | import sbt.Plugins 29 | import sbt.Setting 30 | import sbt.plugins.JvmPlugin 31 | 32 | object LambdaJSPlugin extends AutoPlugin { 33 | 34 | override def trigger: PluginTrigger = noTrigger 35 | 36 | override def requires: Plugins = ScalaJSPlugin && NpmPackagePlugin && JvmPlugin 37 | 38 | override def projectSettings: Seq[Setting[_]] = Seq( 39 | libraryDependencies += 40 | BuildInfo.organization %%% BuildInfo.name.drop(4) % BuildInfo.version, 41 | scalaJSUseMainModuleInitializer := true, 42 | scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule)), 43 | npmPackageOutputFilename := "index.js", 44 | npmPackageStage := Stage.FullOpt 45 | ) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/build.sbt: -------------------------------------------------------------------------------- 1 | scalaVersion := "2.13.16" 2 | enablePlugins(LambdaJSPlugin) 3 | -------------------------------------------------------------------------------- /sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | -------------------------------------------------------------------------------- /sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.typelevel" % "sbt-feral-lambda" % sys.props("plugin.version")) 2 | -------------------------------------------------------------------------------- /sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/src/main/scala/mySimpleHandler.scala: -------------------------------------------------------------------------------- 1 | import cats.effect._ 2 | import feral.lambda._ 3 | 4 | object mySimpleHandler extends IOLambda.Simple[Unit, INothing] { 5 | def apply(event: Unit, context: Context[IO], init: Init): IO[Option[INothing]] = IO.none 6 | } 7 | -------------------------------------------------------------------------------- /sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/test: -------------------------------------------------------------------------------- 1 | > npmPackage 2 | $ exists target/scala-2.13/npm-package/index.js 3 | $ exists target/scala-2.13/npm-package/package.json 4 | $ exec node test-export.js 5 | -------------------------------------------------------------------------------- /sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/test-export.js: -------------------------------------------------------------------------------- 1 | if (typeof require('./target/scala-2.13/npm-package/index.js').mySimpleHandler === 'function') 2 | process.exit(0) 3 | else 4 | process.exit(1) 5 | -------------------------------------------------------------------------------- /scalafix/input/src/main/scala/example/V0_3_0Rewrites.scala: -------------------------------------------------------------------------------- 1 | /* rule=V0_3_0Rewrites */ 2 | 3 | package example 4 | 5 | // format: off 6 | import cats.effect.Concurrent 7 | import feral.lambda.LambdaEnv 8 | import feral.lambda.ApiGatewayProxyLambdaEnv 9 | import feral.lambda.DynamoDbStreamLambdaEnv 10 | import feral.lambda.S3BatchLambdaEnv 11 | import feral.lambda.SnsLambdaEnv 12 | import feral.lambda.SqsLambdaEnv 13 | import feral.lambda.events.APIGatewayProxyRequestEvent 14 | import feral.lambda.events.APIGatewayProxyResponseEvent 15 | import feral.lambda.events.ApiGatewayProxyStructuredResultV2 16 | import feral.lambda.http4s.ApiGatewayProxyHandler 17 | import org.http4s.HttpApp 18 | // format: on 19 | 20 | class Foo[F[_], E] { 21 | 22 | def bar(implicit env: LambdaEnv[F, E]): Unit = ??? 23 | 24 | } 25 | 26 | object Handlers { 27 | def handler1[F[_]: Concurrent]( 28 | implicit env: ApiGatewayProxyLambdaEnv[F] 29 | ): F[Option[ApiGatewayProxyStructuredResultV2]] = 30 | ApiGatewayProxyHandler.httpApp(HttpApp.notFound) 31 | def handler2[F[_]](implicit env: DynamoDbStreamLambdaEnv[F]): Unit = ??? 32 | def handler3[F[_]](implicit env: S3BatchLambdaEnv[F]): Unit = ??? 33 | def handler4[F[_]](implicit env: SnsLambdaEnv[F]): Unit = ??? 34 | def handler5[F[_]](implicit env: SqsLambdaEnv[F]): Unit = ??? 35 | def handler6(event: APIGatewayProxyRequestEvent): APIGatewayProxyResponseEvent = ??? 36 | } 37 | -------------------------------------------------------------------------------- /scalafix/output/src/main/scala/example/V0_3_0Rewrites.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | // format: off 4 | import cats.effect.Concurrent 5 | import feral.lambda.events.ApiGatewayProxyStructuredResultV2 6 | import org.http4s.HttpApp 7 | import feral.lambda.{ ApiGatewayProxyInvocationV2, DynamoDbStreamInvocation, Invocation, S3BatchInvocation, SnsInvocation, SqsInvocation } 8 | import feral.lambda.events.{ ApiGatewayProxyEvent, ApiGatewayProxyResult } 9 | import feral.lambda.http4s.ApiGatewayProxyHandlerV2 10 | // format: on 11 | 12 | class Foo[F[_], E] { 13 | 14 | def bar(implicit env: Invocation[F, E]): Unit = ??? 15 | 16 | } 17 | 18 | object Handlers { 19 | def handler1[F[_]: Concurrent]( 20 | implicit env: ApiGatewayProxyInvocationV2[F] 21 | ): F[Option[ApiGatewayProxyStructuredResultV2]] = 22 | ApiGatewayProxyHandlerV2.apply(HttpApp.notFound) 23 | def handler2[F[_]](implicit env: DynamoDbStreamInvocation[F]): Unit = ??? 24 | def handler3[F[_]](implicit env: S3BatchInvocation[F]): Unit = ??? 25 | def handler4[F[_]](implicit env: SnsInvocation[F]): Unit = ??? 26 | def handler5[F[_]](implicit env: SqsInvocation[F]): Unit = ??? 27 | def handler6(event: ApiGatewayProxyEvent): ApiGatewayProxyResult = ??? 28 | } 29 | -------------------------------------------------------------------------------- /scalafix/rules/src/main/resources/META-INF/services/scalafix.v1.Rule: -------------------------------------------------------------------------------- 1 | feral.scalafix.V0_3_0Rewrites 2 | -------------------------------------------------------------------------------- /scalafix/rules/src/main/scala/feral/scalafix/V0_3_0Rewrites.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.scalafix 18 | 19 | import scalafix.v1._ 20 | 21 | class V0_3_0Rewrites extends SemanticRule("V0_3_0Rewrites") { 22 | override def fix(implicit doc: SemanticDocument): Patch = 23 | Patch.replaceSymbols( 24 | "feral.lambda.LambdaEnv" -> "feral.lambda.Invocation", 25 | "feral.lambda.ApiGatewayProxyLambdaEnv" -> "feral.lambda.ApiGatewayProxyInvocationV2", 26 | "feral.lambda.DynamoDbStreamLambdaEnv" -> "feral.lambda.DynamoDbStreamInvocation", 27 | "feral.lambda.S3BatchLambdaEnv" -> "feral.lambda.S3BatchInvocation", 28 | "feral.lambda.SnsLambdaEnv" -> "feral.lambda.SnsInvocation", 29 | "feral.lambda.SqsLambdaEnv" -> "feral.lambda.SqsInvocation", 30 | "feral.lambda.events.APIGatewayProxyRequestEvent" -> "feral.lambda.events.ApiGatewayProxyEvent", 31 | "feral.lambda.events.APIGatewayProxyResponseEvent" -> "feral.lambda.events.ApiGatewayProxyResult", 32 | "feral.lambda.http4s.ApiGatewayProxyHandler.httpApp" -> "feral.lambda.http4s.ApiGatewayProxyHandlerV2.apply", 33 | "feral.lambda.http4s.ApiGatewayProxyHandler.apply" -> "feral.lambda.http4s.ApiGatewayProxyHandlerV2.httpRoutes", 34 | "feral.lambda.http4s.ApiGatewayProxyHandler.httpRoutes" -> "feral.lambda.http4s.ApiGatewayProxyHandlerV2.httpRoutes" 35 | ) + Patch.removeGlobalImport(Symbol("feral/lambda/http4s/ApiGatewayProxyHandler.")) 36 | } 37 | -------------------------------------------------------------------------------- /scalafix/tests/src/test/scala/feral/scalafix/RuleSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package feral.scalafix 18 | 19 | import org.scalatest.funsuite.AnyFunSuiteLike 20 | import scalafix.testkit._ 21 | 22 | class RuleSuite extends AbstractSemanticRuleSuite with AnyFunSuiteLike { 23 | runAllTests() 24 | } 25 | --------------------------------------------------------------------------------