From 067028fa8143e4a407e3ca885aa0ad6118e69e3a Mon Sep 17 00:00:00 2001
From: Arman Bilge <armanbilge@gmail.com>
Date: Mon, 20 Nov 2023 22:21:47 +0000
Subject: [PATCH 1/3] Re-encode `Context`+events: improve compat+mocking

---
 build.sbt                                     |   1 +
 ...udFormationCustomResourceArbitraries.scala |  10 +-
 .../http4s/ApiGatewayProxyHandler.scala       |  15 +-
 .../http4s/ApiGatewayProxyHandlerSuite.scala  |  38 ++-
 .../scala/feral/lambda/ContextPlatform.scala  |  10 +-
 .../scala/feral/lambda/ContextPlatform.scala  |  10 +-
 .../src/main/scala/feral/lambda/Context.scala | 215 +++++++++++++----
 .../events/APIGatewayProxyRequestEvent.scala  |  59 -----
 .../lambda/events/ApiGatewayProxyEvent.scala  |  99 ++++++++
 .../events/ApiGatewayProxyEventV2.scala       |  73 ++++--
 ...vent.scala => ApiGatewayProxyResult.scala} |  27 ++-
 .../events/ApiGatewayProxyResultV2.scala      |  35 ++-
 .../lambda/events/DynamoDbStreamEvent.scala   | 177 +++++++++++---
 .../lambda/events/KinesisStreamEvent.scala    | 127 +++++++---
 .../feral/lambda/events/S3BatchEvent.scala    |  71 ++++--
 .../feral/lambda/events/S3BatchResult.scala   |  66 +++--
 .../scala/feral/lambda/events/S3Event.scala   | 228 ++++++++++++++----
 .../scala/feral/lambda/events/SnsEvent.scala  | 122 +++++++---
 .../scala/feral/lambda/events/SqsEvent.scala  | 136 ++++++++---
 .../events/{package.scala => codecs.scala}    |  19 +-
 .../scala/feral/lambda/IOLambdaSuite.scala    |   2 +-
 ....scala => ApiGatewayProxyEventSuite.scala} |   8 +-
 .../lambda/events/InstantDecoderSuite.scala   |   4 +-
 .../feral/lambda/events/S3EventSuite.scala    |   3 +-
 24 files changed, 1175 insertions(+), 380 deletions(-)
 delete mode 100644 lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyRequestEvent.scala
 create mode 100644 lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEvent.scala
 rename lambda/shared/src/main/scala/feral/lambda/events/{APIGatewayProxyResponseEvent.scala => ApiGatewayProxyResult.scala} (55%)
 rename lambda/shared/src/main/scala/feral/lambda/events/{package.scala => codecs.scala} (62%)
 rename lambda/shared/src/test/scala/feral/lambda/events/{APIGatewayProxyEventSuite.scala => ApiGatewayProxyEventSuite.scala} (96%)

diff --git a/build.sbt b/build.sbt
index 1a23a529..0bee1739 100644
--- a/build.sbt
+++ b/build.sbt
@@ -101,6 +101,7 @@ lazy val lambda = crossProject(JSPlatform, JVMPlatform)
       "org.tpolecat" %%% "natchez-core" % natchezVersion,
       "io.circe" %%% "circe-scodec" % circeVersion,
       "io.circe" %%% "circe-jawn" % circeVersion,
+      "com.comcast" %%% "ip4s-core" % "3.4.0",
       "org.scodec" %%% "scodec-bits" % "1.1.38",
       "org.scalameta" %%% "munit-scalacheck" % munitVersion % Test,
       "org.typelevel" %%% "munit-cats-effect-3" % munitCEVersion % Test,
diff --git a/lambda-cloudformation-custom-resource/src/test/scala/feral/lambda/cloudformation/CloudFormationCustomResourceArbitraries.scala b/lambda-cloudformation-custom-resource/src/test/scala/feral/lambda/cloudformation/CloudFormationCustomResourceArbitraries.scala
index 59ad4502..07387437 100644
--- a/lambda-cloudformation-custom-resource/src/test/scala/feral/lambda/cloudformation/CloudFormationCustomResourceArbitraries.scala
+++ b/lambda-cloudformation-custom-resource/src/test/scala/feral/lambda/cloudformation/CloudFormationCustomResourceArbitraries.scala
@@ -40,7 +40,7 @@ trait CloudFormationCustomResourceArbitraries {
       appVersionName <- arbitrary[String]
       appVersionCode <- arbitrary[String]
       appPackageName <- arbitrary[String]
-    } yield new ClientContextClient(
+    } yield ClientContextClient(
       installationId,
       appTitle,
       appVersionName,
@@ -56,14 +56,14 @@ trait CloudFormationCustomResourceArbitraries {
       make <- arbitrary[String]
       model <- arbitrary[String]
       locale <- arbitrary[String]
-    } yield new ClientContextEnv(platformVersion, platform, make, model, locale)
+    } yield ClientContextEnv(platformVersion, platform, make, model, locale)
   implicit val arbClientContextEnv: Arbitrary[ClientContextEnv] = Arbitrary(genClientContextEnv)
 
   val genCognitoIdentity: Gen[CognitoIdentity] =
     for {
       identityId <- arbitrary[String]
       identityPoolId <- arbitrary[String]
-    } yield new CognitoIdentity(identityId, identityPoolId)
+    } yield CognitoIdentity(identityId, identityPoolId)
   implicit val arbCognitoIdentity: Arbitrary[CognitoIdentity] = Arbitrary(genCognitoIdentity)
 
   val genClientContext: Gen[ClientContext] =
@@ -71,7 +71,7 @@ trait CloudFormationCustomResourceArbitraries {
       client <- arbitrary[ClientContextClient]
       env <- arbitrary[ClientContextEnv]
       custom <- arbitrary[JsonObject]
-    } yield new ClientContext(client, env, custom)
+    } yield ClientContext(client, env, custom)
   implicit val arbClientContext: Arbitrary[ClientContext] = Arbitrary(genClientContext)
 
   def genContext[F[_]: Applicative]: Gen[Context[F]] =
@@ -86,7 +86,7 @@ trait CloudFormationCustomResourceArbitraries {
       identity <- arbitrary[Option[CognitoIdentity]]
       clientContext <- arbitrary[Option[ClientContext]]
       remainingTime <- arbitrary[FiniteDuration]
-    } yield new Context(
+    } yield Context(
       functionName,
       functionVersion,
       invokedFunctionArn,
diff --git a/lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala b/lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala
index 2100afa7..bb3ad53f 100644
--- a/lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala
+++ b/lambda-http4s/src/main/scala/feral/lambda/http4s/ApiGatewayProxyHandler.scala
@@ -23,6 +23,7 @@ import feral.lambda.events.ApiGatewayProxyEventV2
 import feral.lambda.events.ApiGatewayProxyStructuredResultV2
 import fs2.Stream
 import org.http4s.Charset
+import org.http4s.Header
 import org.http4s.Headers
 import org.http4s.{HttpApp, HttpRoutes}
 import org.http4s.Method
@@ -60,7 +61,7 @@ object ApiGatewayProxyHandler {
           response.status.code,
           (headers - `Set-Cookie`.name).map {
             case (name, values) =>
-              name.toString -> values.mkString(",")
+              name -> values.mkString(",")
           },
           responseBody,
           isBase64Encoded,
@@ -73,8 +74,16 @@ object ApiGatewayProxyHandler {
       event: ApiGatewayProxyEventV2): F[Request[F]] = for {
     method <- Method.fromString(event.requestContext.http.method).liftTo[F]
     uri <- Uri.fromString(event.rawPath + "?" + event.rawQueryString).liftTo[F]
-    cookies = event.cookies.filter(_.nonEmpty).map(Cookie.name.toString -> _.mkString("; "))
-    headers = Headers(cookies.toList ::: event.headers.toList)
+    headers = {
+      val builder = List.newBuilder[Header.Raw]
+
+      event.headers.foreachEntry(builder += Header.Raw(_, _))
+      event.cookies.filter(_.nonEmpty).foreach { cs =>
+        builder += Header.Raw(Cookie.name, cs.mkString("; "))
+      }
+
+      Headers(builder.result())
+    }
     readBody =
       if (event.isBase64Encoded)
         fs2.text.base64.decode[F]
diff --git a/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala b/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala
index b0ae005b..d1ab4a4c 100644
--- a/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala
+++ b/lambda-http4s/src/test/scala-2/feral/lambda/http4s/ApiGatewayProxyHandlerSuite.scala
@@ -51,26 +51,24 @@ class ApiGatewayProxyHandlerSuite extends CatsEffectSuite {
   }
 
   def expectedHeaders = Headers(
-    ("cookie", "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2; regStatus=pre-register"),
-    ("x-forwarded-port", "443"),
-    (
-      "accept",
-      "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"),
-    ("sec-fetch-site", "cross-site"),
-    ("x-amzn-trace-id", "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e"),
-    ("sec-fetch-mode", "navigate"),
-    ("sec-fetch-user", "?1"),
-    ("accept-encoding", "gzip, deflate, br"),
-    ("x-forwarded-for", "205.255.255.176"),
-    ("upgrade-insecure-requests", "1"),
-    (
-      "user-agent",
-      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36"),
-    ("accept-language", "en-US,en;q=0.9"),
-    ("x-forwarded-proto", "https"),
-    ("host", "r3pmxmplak.execute-api.us-east-2.amazonaws.com"),
-    ("content-length", "0"),
-    ("sec-fetch-dest", "document")
+    "content-length" -> "0",
+    "accept-language" -> "en-US,en;q=0.9",
+    "sec-fetch-dest" -> "document",
+    "sec-fetch-user" -> "?1",
+    "x-amzn-trace-id" -> "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e",
+    "host" -> "r3pmxmplak.execute-api.us-east-2.amazonaws.com",
+    "sec-fetch-mode" -> "navigate",
+    "accept-encoding" -> "gzip, deflate, br",
+    "accept" ->
+      "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
+    "sec-fetch-site" -> "cross-site",
+    "x-forwarded-port" -> "443",
+    "x-forwarded-proto" -> "https",
+    "upgrade-insecure-requests" -> "1",
+    "user-agent" ->
+      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36",
+    "x-forwarded-for" -> "205.255.255.176",
+    "cookie" -> "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2; regStatus=pre-register"
   )
 
 }
diff --git a/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala b/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala
index 14221a86..ddb1176a 100644
--- a/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala
+++ b/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala
@@ -25,7 +25,7 @@ import scala.concurrent.duration._
 private[lambda] trait ContextCompanionPlatform {
 
   private[lambda] def fromJS[F[_]: Sync](context: facade.Context): Context[F] =
-    new Context(
+    Context(
       context.functionName,
       context.functionVersion,
       context.invokedFunctionArn,
@@ -34,21 +34,21 @@ private[lambda] trait ContextCompanionPlatform {
       context.logGroupName,
       context.logStreamName,
       context.identity.toOption.map { identity =>
-        new CognitoIdentity(identity.cognitoIdentityId, identity.cognitoIdentityPoolId)
+        CognitoIdentity(identity.cognitoIdentityId, identity.cognitoIdentityPoolId)
       },
       context
         .clientContext
         .toOption
         .map { clientContext =>
-          new ClientContext(
-            new ClientContextClient(
+          ClientContext(
+            ClientContextClient(
               clientContext.client.installationId,
               clientContext.client.appTitle,
               clientContext.client.appVersionName,
               clientContext.client.appVersionCode,
               clientContext.client.appPackageName
             ),
-            new ClientContextEnv(
+            ClientContextEnv(
               clientContext.env.platformVersion,
               clientContext.env.platform,
               clientContext.env.make,
diff --git a/lambda/jvm/src/main/scala/feral/lambda/ContextPlatform.scala b/lambda/jvm/src/main/scala/feral/lambda/ContextPlatform.scala
index ad650a5a..e3281bd9 100644
--- a/lambda/jvm/src/main/scala/feral/lambda/ContextPlatform.scala
+++ b/lambda/jvm/src/main/scala/feral/lambda/ContextPlatform.scala
@@ -27,7 +27,7 @@ import scala.jdk.CollectionConverters._
 private[lambda] trait ContextCompanionPlatform {
 
   private[lambda] def fromJava[F[_]: Sync](context: runtime.Context): Context[F] =
-    new Context(
+    Context(
       context.getFunctionName(),
       context.getFunctionVersion(),
       context.getInvokedFunctionArn(),
@@ -36,18 +36,18 @@ private[lambda] trait ContextCompanionPlatform {
       context.getLogGroupName(),
       context.getLogStreamName(),
       Option(context.getIdentity()).map { identity =>
-        new CognitoIdentity(identity.getIdentityId(), identity.getIdentityPoolId())
+        CognitoIdentity(identity.getIdentityId(), identity.getIdentityPoolId())
       },
       Option(context.getClientContext()).map { clientContext =>
-        new ClientContext(
-          new ClientContextClient(
+        ClientContext(
+          ClientContextClient(
             clientContext.getClient().getInstallationId(),
             clientContext.getClient().getAppTitle(),
             clientContext.getClient().getAppVersionName(),
             clientContext.getClient().getAppVersionCode(),
             clientContext.getClient().getAppPackageName()
           ),
-          new ClientContextEnv(
+          ClientContextEnv(
             clientContext.getEnvironment().get("platformVersion"),
             clientContext.getEnvironment().get("platform"),
             clientContext.getEnvironment().get("make"),
diff --git a/lambda/shared/src/main/scala/feral/lambda/Context.scala b/lambda/shared/src/main/scala/feral/lambda/Context.scala
index 113b94eb..dcdf6d19 100644
--- a/lambda/shared/src/main/scala/feral/lambda/Context.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/Context.scala
@@ -17,60 +17,173 @@
 package feral.lambda
 
 import cats.~>
+import cats.Applicative
 import io.circe.JsonObject
 
 import scala.concurrent.duration.FiniteDuration
 
-final class Context[F[_]] private[lambda] (
-    val functionName: String,
-    val functionVersion: String,
-    val invokedFunctionArn: String,
-    val memoryLimitInMB: Int,
-    val awsRequestId: String,
-    val logGroupName: String,
-    val logStreamName: String,
-    val identity: Option[CognitoIdentity],
-    val clientContext: Option[ClientContext],
-    val remainingTime: F[FiniteDuration]
-) {
-  def mapK[G[_]](f: F ~> G): Context[G] = new Context(
-    functionName,
-    functionVersion,
-    invokedFunctionArn,
-    memoryLimitInMB,
-    awsRequestId,
-    logGroupName,
-    logStreamName,
-    identity,
-    clientContext,
-    f(remainingTime))
+sealed abstract class Context[F[_]] {
+  def functionName: String
+  def functionVersion: String
+  def invokedFunctionArn: String
+  def memoryLimitInMB: Int
+  def awsRequestId: String
+  def logGroupName: String
+  def logStreamName: String
+  def identity: Option[CognitoIdentity]
+  def clientContext: Option[ClientContext]
+  def remainingTime: F[FiniteDuration]
+
+  final def mapK[G[_]](f: F ~> G): Context[G] =
+    new Context.Impl(
+      functionName,
+      functionVersion,
+      invokedFunctionArn,
+      memoryLimitInMB,
+      awsRequestId,
+      logGroupName,
+      logStreamName,
+      identity,
+      clientContext,
+      f(remainingTime)
+    )
+
+}
+
+object Context extends ContextCompanionPlatform {
+  def apply[F[_]](
+      functionName: String,
+      functionVersion: String,
+      invokedFunctionArn: String,
+      memoryLimitInMB: Int,
+      awsRequestId: String,
+      logGroupName: String,
+      logStreamName: String,
+      identity: Option[CognitoIdentity],
+      clientContext: Option[ClientContext],
+      remainingTime: F[FiniteDuration]
+  )(implicit F: Applicative[F]): Context[F] = {
+    val _ = F // might be useful for future compatibility
+    new Impl(
+      functionName,
+      functionVersion,
+      invokedFunctionArn,
+      memoryLimitInMB,
+      awsRequestId,
+      logGroupName,
+      logStreamName,
+      identity,
+      clientContext,
+      remainingTime)
+  }
+
+  private final case class Impl[F[_]](
+      functionName: String,
+      functionVersion: String,
+      invokedFunctionArn: String,
+      memoryLimitInMB: Int,
+      awsRequestId: String,
+      logGroupName: String,
+      logStreamName: String,
+      identity: Option[CognitoIdentity],
+      clientContext: Option[ClientContext],
+      remainingTime: F[FiniteDuration]
+  ) extends Context[F] {
+    override def productPrefix = "Context"
+  }
+}
+
+sealed abstract class CognitoIdentity {
+  def identityId: String
+  def identityPoolId: String
+}
+
+object CognitoIdentity {
+  def apply(identityId: String, identityPoolId: String): CognitoIdentity =
+    new Impl(identityId, identityPoolId)
+
+  private final case class Impl(
+      val identityId: String,
+      val identityPoolId: String
+  ) extends CognitoIdentity {
+    override def productPrefix = "CognitoIdentity"
+  }
+}
+
+sealed abstract class ClientContext {
+  def client: ClientContextClient
+  def env: ClientContextEnv
+  def custom: JsonObject
 }
 
-object Context extends ContextCompanionPlatform
-
-final class CognitoIdentity(
-    val identityId: String,
-    val identityPoolId: String
-)
-
-final class ClientContext(
-    val client: ClientContextClient,
-    val env: ClientContextEnv,
-    val custom: JsonObject
-)
-
-final class ClientContextClient(
-    val installationId: String,
-    val appTitle: String,
-    val appVersionName: String,
-    val appVersionCode: String,
-    val appPackageName: String
-)
-
-final class ClientContextEnv(
-    val platformVersion: String,
-    val platform: String,
-    val make: String,
-    val model: String,
-    val locale: String
-)
+object ClientContext {
+  def apply(
+      client: ClientContextClient,
+      env: ClientContextEnv,
+      custom: JsonObject
+  ): ClientContext =
+    new Impl(client, env, custom)
+
+  private final case class Impl(
+      client: ClientContextClient,
+      env: ClientContextEnv,
+      custom: JsonObject
+  ) extends ClientContext
+}
+
+sealed abstract class ClientContextClient {
+  def installationId: String
+  def appTitle: String
+  def appVersionName: String
+  def appVersionCode: String
+  def appPackageName: String
+}
+
+object ClientContextClient {
+  def apply(
+      installationId: String,
+      appTitle: String,
+      appVersionName: String,
+      appVersionCode: String,
+      appPackageName: String
+  ): ClientContextClient =
+    new Impl(installationId, appTitle, appVersionName, appVersionCode, appPackageName)
+
+  private final case class Impl(
+      installationId: String,
+      appTitle: String,
+      appVersionName: String,
+      appVersionCode: String,
+      appPackageName: String
+  ) extends ClientContextClient {
+    override def productPrefix = "ClientContextClient"
+  }
+}
+
+sealed abstract class ClientContextEnv {
+  def platformVersion: String
+  def platform: String
+  def make: String
+  def model: String
+  def locale: String
+}
+
+object ClientContextEnv {
+  def apply(
+      platformVersion: String,
+      platform: String,
+      make: String,
+      model: String,
+      locale: String): ClientContextEnv =
+    new Impl(platformVersion, platform, make, model, locale)
+
+  private final case class Impl(
+      platformVersion: String,
+      platform: String,
+      make: String,
+      model: String,
+      locale: String
+  ) extends ClientContextEnv {
+    override def productPrefix = "ClientContextEnv"
+  }
+}
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyRequestEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyRequestEvent.scala
deleted file mode 100644
index 36ae9229..00000000
--- a/lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyRequestEvent.scala
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright 2021 Typelevel
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package feral.lambda
-package events
-
-import io.circe.Decoder
-import natchez.Kernel
-import org.typelevel.ci.CIString
-
-final case class APIGatewayProxyRequestEvent(
-    body: Option[String],
-    resource: String,
-    path: String,
-    httpMethod: String,
-    isBase64Encoded: Boolean,
-    queryStringParameters: Option[Map[String, String]],
-    multiValueQueryStringParameters: Option[Map[String, List[String]]],
-    pathParameters: Option[Map[String, String]],
-    stageVariables: Option[Map[String, String]],
-    headers: Option[Map[String, String]],
-    multiValueHeaders: Option[Map[String, List[String]]]
-)
-
-object APIGatewayProxyRequestEvent {
-
-  implicit def decoder: Decoder[APIGatewayProxyRequestEvent] = Decoder.forProduct11(
-    "body",
-    "resource",
-    "path",
-    "httpMethod",
-    "isBase64Encoded",
-    "queryStringParameters",
-    "multiValueQueryStringParameters",
-    "pathParameters",
-    "stageVariables",
-    "headers",
-    "multiValueHeaders"
-  )(APIGatewayProxyRequestEvent.apply)
-
-  implicit def kernelSource: KernelSource[APIGatewayProxyRequestEvent] =
-    e =>
-      Kernel(
-        e.headers.getOrElse(Map.empty).map { case (name, value) => CIString(name) -> value })
-
-}
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEvent.scala
new file mode 100644
index 00000000..e7b1fc34
--- /dev/null
+++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEvent.scala
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2021 Typelevel
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package feral.lambda
+package events
+
+import io.circe.Decoder
+import natchez.Kernel
+import org.typelevel.ci.CIString
+
+sealed abstract class ApiGatewayProxyEvent {
+  def body: Option[String]
+  def resource: String
+  def path: String
+  def httpMethod: String
+  def isBase64Encoded: Boolean
+  def queryStringParameters: Option[Map[String, String]]
+  def multiValueQueryStringParameters: Option[Map[String, List[String]]]
+  def pathParameters: Option[Map[String, String]]
+  def stageVariables: Option[Map[String, String]]
+  def headers: Option[Map[CIString, String]]
+  def multiValueHeaders: Option[Map[CIString, List[String]]]
+}
+
+object ApiGatewayProxyEvent {
+
+  def apply(
+      body: Option[String],
+      resource: String,
+      path: String,
+      httpMethod: String,
+      isBase64Encoded: Boolean,
+      queryStringParameters: Option[Map[String, String]],
+      multiValueQueryStringParameters: Option[Map[String, List[String]]],
+      pathParameters: Option[Map[String, String]],
+      stageVariables: Option[Map[String, String]],
+      headers: Option[Map[CIString, String]],
+      multiValueHeaders: Option[Map[CIString, List[String]]]): ApiGatewayProxyEvent =
+    new Impl(
+      body,
+      resource,
+      path,
+      httpMethod,
+      isBase64Encoded,
+      queryStringParameters,
+      multiValueQueryStringParameters,
+      pathParameters,
+      stageVariables,
+      headers,
+      multiValueHeaders
+    )
+
+  import codecs.decodeKeyCIString
+  implicit def decoder: Decoder[ApiGatewayProxyEvent] = Decoder.forProduct11(
+    "body",
+    "resource",
+    "path",
+    "httpMethod",
+    "isBase64Encoded",
+    "queryStringParameters",
+    "multiValueQueryStringParameters",
+    "pathParameters",
+    "stageVariables",
+    "headers",
+    "multiValueHeaders"
+  )(ApiGatewayProxyEvent.apply)
+
+  implicit def kernelSource: KernelSource[ApiGatewayProxyEvent] =
+    e => Kernel(e.headers.getOrElse(Map.empty))
+
+  private final case class Impl(
+      body: Option[String],
+      resource: String,
+      path: String,
+      httpMethod: String,
+      isBase64Encoded: Boolean,
+      queryStringParameters: Option[Map[String, String]],
+      multiValueQueryStringParameters: Option[Map[String, List[String]]],
+      pathParameters: Option[Map[String, String]],
+      stageVariables: Option[Map[String, String]],
+      headers: Option[Map[CIString, String]],
+      multiValueHeaders: Option[Map[CIString, List[String]]]
+  ) extends ApiGatewayProxyEvent {
+    override def productPrefix = "ApiGatewayProxyEvent"
+  }
+}
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala
index 8607da04..b805c22e 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala
@@ -21,28 +21,61 @@ import io.circe.Decoder
 import natchez.Kernel
 import org.typelevel.ci.CIString
 
-final case class Http(method: String)
+sealed abstract class Http {
+  def method: String
+}
+
 object Http {
-  implicit val decoder: Decoder[Http] = Decoder.forProduct1("method")(Http.apply)
+  def apply(method: String): Http =
+    new Impl(method)
+
+  private[events] implicit val decoder: Decoder[Http] =
+    Decoder.forProduct1("method")(Http.apply)
+
+  private final case class Impl(method: String) extends Http {
+    override def productPrefix = "Http"
+  }
+}
+
+sealed abstract class RequestContext {
+  def http: Http
 }
-final case class RequestContext(http: Http)
 
 object RequestContext {
-  implicit val decoder: Decoder[RequestContext] =
+  def apply(http: Http): RequestContext =
+    new Impl(http)
+
+  private[events] implicit val decoder: Decoder[RequestContext] =
     Decoder.forProduct1("http")(RequestContext.apply)
+
+  final case class Impl(http: Http) extends RequestContext {
+    override def productPrefix = "RequestContext"
+  }
 }
 
-final case class ApiGatewayProxyEventV2(
-    rawPath: String,
-    rawQueryString: String,
-    cookies: Option[List[String]],
-    headers: Map[String, String],
-    requestContext: RequestContext,
-    body: Option[String],
-    isBase64Encoded: Boolean
-)
+sealed abstract class ApiGatewayProxyEventV2 {
+  def rawPath: String
+  def rawQueryString: String
+  def cookies: Option[List[String]]
+  def headers: Map[CIString, String]
+  def requestContext: RequestContext
+  def body: Option[String]
+  def isBase64Encoded: Boolean
+}
 
 object ApiGatewayProxyEventV2 {
+  def apply(
+      rawPath: String,
+      rawQueryString: String,
+      cookies: Option[List[String]],
+      headers: Map[CIString, String],
+      requestContext: RequestContext,
+      body: Option[String],
+      isBase64Encoded: Boolean
+  ): ApiGatewayProxyEventV2 =
+    new Impl(rawPath, rawQueryString, cookies, headers, requestContext, body, isBase64Encoded)
+
+  import codecs.decodeKeyCIString
   implicit def decoder: Decoder[ApiGatewayProxyEventV2] = Decoder.forProduct7(
     "rawPath",
     "rawQueryString",
@@ -54,5 +87,17 @@ object ApiGatewayProxyEventV2 {
   )(ApiGatewayProxyEventV2.apply)
 
   implicit def kernelSource: KernelSource[ApiGatewayProxyEventV2] =
-    e => Kernel(e.headers.map { case (name, value) => CIString(name) -> value })
+    e => Kernel(e.headers)
+
+  private final case class Impl(
+      rawPath: String,
+      rawQueryString: String,
+      cookies: Option[List[String]],
+      headers: Map[CIString, String],
+      requestContext: RequestContext,
+      body: Option[String],
+      isBase64Encoded: Boolean
+  ) extends ApiGatewayProxyEventV2 {
+    override def productPrefix = "ApiGatewayProxyEventV2"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyResponseEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala
similarity index 55%
rename from lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyResponseEvent.scala
rename to lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala
index f3d0fa06..5129c02e 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/APIGatewayProxyResponseEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala
@@ -18,18 +18,31 @@ package feral.lambda.events
 
 import io.circe.Encoder
 
-final case class APIGatewayProxyResponseEvent(
-    statusCode: Int,
-    body: String,
-    isBase64Encoded: Boolean
-)
+sealed abstract class ApiGatewayProxyResult {
+  def statusCode: Int
+  def body: String
+  def isBase64Encoded: Boolean
+}
+
+object ApiGatewayProxyResult {
 
-object APIGatewayProxyResponseEvent {
+  def apply(
+      statusCode: Int,
+      body: String,
+      isBase64Encoded: Boolean): ApiGatewayProxyResult =
+    new Impl(statusCode, body, isBase64Encoded)
 
-  implicit def encoder: Encoder[APIGatewayProxyResponseEvent] = Encoder.forProduct3(
+  implicit def encoder: Encoder[ApiGatewayProxyResult] = Encoder.forProduct3(
     "statusCode",
     "body",
     "isBase64Encoded"
   )(r => (r.statusCode, r.body, r.isBase64Encoded))
 
+  private final case class Impl(
+      statusCode: Int,
+      body: String,
+      isBase64Encoded: Boolean
+  ) extends ApiGatewayProxyResult {
+    override def productPrefix = "ApiGatewayProxyResult"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala
index ef70c9ee..8f37e14b 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResultV2.scala
@@ -17,16 +17,27 @@
 package feral.lambda.events
 
 import io.circe.Encoder
+import org.typelevel.ci.CIString
 
-final case class ApiGatewayProxyStructuredResultV2(
-    statusCode: Int,
-    headers: Map[String, String],
-    body: String,
-    isBase64Encoded: Boolean,
-    cookies: List[String]
-)
+sealed abstract class ApiGatewayProxyStructuredResultV2 {
+  def statusCode: Int
+  def headers: Map[CIString, String]
+  def body: String
+  def isBase64Encoded: Boolean
+  def cookies: List[String]
+}
 
 object ApiGatewayProxyStructuredResultV2 {
+  def apply(
+      statusCode: Int,
+      headers: Map[CIString, String],
+      body: String,
+      isBase64Encoded: Boolean,
+      cookies: List[String]
+  ): ApiGatewayProxyStructuredResultV2 =
+    new Impl(statusCode, headers, body, isBase64Encoded, cookies)
+
+  import codecs.encodeKeyCIString
   implicit def encoder: Encoder[ApiGatewayProxyStructuredResultV2] = Encoder.forProduct5(
     "statusCode",
     "headers",
@@ -34,4 +45,14 @@ object ApiGatewayProxyStructuredResultV2 {
     "isBase64Encoded",
     "cookies"
   )(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded, r.cookies))
+
+  private final case class Impl(
+      statusCode: Int,
+      headers: Map[CIString, String],
+      body: String,
+      isBase64Encoded: Boolean,
+      cookies: List[String]
+  ) extends ApiGatewayProxyStructuredResultV2 {
+    override def productPrefix = "ApiGatewayProxyStructuredResultV2"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala
index fddb26cd..9ec412ee 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala
@@ -22,21 +22,35 @@ import io.circe.Json
 import io.circe.scodec.decodeByteVector
 import scodec.bits.ByteVector
 
-final case class AttributeValue(
-    b: Option[ByteVector],
-    bs: Option[List[ByteVector]],
-    bool: Option[Boolean],
-    l: Option[List[AttributeValue]],
-    m: Option[Map[String, AttributeValue]],
-    n: Option[String],
-    ns: Option[List[String]],
-    nul: Boolean,
-    s: Option[String],
-    ss: Option[List[String]]
-)
+sealed abstract class AttributeValue {
+  def b: Option[ByteVector]
+  def bs: Option[List[ByteVector]]
+  def bool: Option[Boolean]
+  def l: Option[List[AttributeValue]]
+  def m: Option[Map[String, AttributeValue]]
+  def n: Option[String]
+  def ns: Option[List[String]]
+  def nul: Boolean
+  def s: Option[String]
+  def ss: Option[List[String]]
+}
 
 object AttributeValue {
-  implicit val decoder: Decoder[AttributeValue] = for {
+  def apply(
+      b: Option[ByteVector],
+      bs: Option[List[ByteVector]],
+      bool: Option[Boolean],
+      l: Option[List[AttributeValue]],
+      m: Option[Map[String, AttributeValue]],
+      n: Option[String],
+      ns: Option[List[String]],
+      nul: Boolean,
+      s: Option[String],
+      ss: Option[List[String]]
+  ): AttributeValue =
+    new Impl(b, bs, bool, l, m, n, ns, nul, s, ss)
+
+  private[events] implicit val decoder: Decoder[AttributeValue] = for {
     b <- Decoder[Option[ByteVector]].at("B")
     bs <- Decoder[Option[List[ByteVector]]].at("BS")
     bool <- Decoder[Option[Boolean]].at("BOOL")
@@ -59,20 +73,54 @@ object AttributeValue {
     s = s,
     ss = ss
   )
+
+  private final case class Impl(
+      b: Option[ByteVector],
+      bs: Option[List[ByteVector]],
+      bool: Option[Boolean],
+      l: Option[List[AttributeValue]],
+      m: Option[Map[String, AttributeValue]],
+      n: Option[String],
+      ns: Option[List[String]],
+      nul: Boolean,
+      s: Option[String],
+      ss: Option[List[String]]
+  ) extends AttributeValue {
+    override def productPrefix = "AttributeValue"
+  }
 }
 
-final case class StreamRecord(
-    approximateCreationDateTime: Option[Double],
-    keys: Option[Map[String, AttributeValue]],
-    newImage: Option[Map[String, AttributeValue]],
-    oldImage: Option[Map[String, AttributeValue]],
-    sequenceNumber: Option[String],
-    sizeBytes: Option[Double],
-    streamViewType: Option[String]
-)
+sealed abstract class StreamRecord {
+  def approximateCreationDateTime: Option[Double]
+  def keys: Option[Map[String, AttributeValue]]
+  def newImage: Option[Map[String, AttributeValue]]
+  def oldImage: Option[Map[String, AttributeValue]]
+  def sequenceNumber: Option[String]
+  def sizeBytes: Option[Double]
+  def streamViewType: Option[String]
+}
 
 object StreamRecord {
-  implicit val decoder: Decoder[StreamRecord] = Decoder.forProduct7(
+  def apply(
+      approximateCreationDateTime: Option[Double],
+      keys: Option[Map[String, AttributeValue]],
+      newImage: Option[Map[String, AttributeValue]],
+      oldImage: Option[Map[String, AttributeValue]],
+      sequenceNumber: Option[String],
+      sizeBytes: Option[Double],
+      streamViewType: Option[String]
+  ): StreamRecord =
+    new Impl(
+      approximateCreationDateTime,
+      keys,
+      newImage,
+      oldImage,
+      sequenceNumber,
+      sizeBytes,
+      streamViewType
+    )
+
+  private[events] implicit val decoder: Decoder[StreamRecord] = Decoder.forProduct7(
     "ApproximateCreationDateTime",
     "Keys",
     "NewImage",
@@ -81,21 +129,54 @@ object StreamRecord {
     "SizeBytes",
     "StreamViewType"
   )(StreamRecord.apply)
+
+  final case class Impl(
+      approximateCreationDateTime: Option[Double],
+      keys: Option[Map[String, AttributeValue]],
+      newImage: Option[Map[String, AttributeValue]],
+      oldImage: Option[Map[String, AttributeValue]],
+      sequenceNumber: Option[String],
+      sizeBytes: Option[Double],
+      streamViewType: Option[String]
+  ) extends StreamRecord {
+    override def productPrefix = "StreamRecord"
+  }
 }
 
-final case class DynamoDbRecord(
-    awsRegion: Option[String],
-    dynamodb: Option[StreamRecord],
-    eventID: Option[String],
-    eventName: Option[String],
-    eventSource: Option[String],
-    eventSourceArn: Option[String],
-    eventVersion: Option[String],
-    userIdentity: Option[Json]
-)
+sealed abstract class DynamoDbRecord {
+  def awsRegion: Option[String]
+  def dynamodb: Option[StreamRecord]
+  def eventId: Option[String]
+  def eventName: Option[String]
+  def eventSource: Option[String]
+  def eventSourceArn: Option[String]
+  def eventVersion: Option[String]
+  def userIdentity: Option[Json]
+}
 
 object DynamoDbRecord {
-  implicit val decoder: Decoder[DynamoDbRecord] = Decoder.forProduct8(
+  def apply(
+      awsRegion: Option[String],
+      dynamodb: Option[StreamRecord],
+      eventId: Option[String],
+      eventName: Option[String],
+      eventSource: Option[String],
+      eventSourceArn: Option[String],
+      eventVersion: Option[String],
+      userIdentity: Option[Json]
+  ): DynamoDbRecord =
+    new Impl(
+      awsRegion,
+      dynamodb,
+      eventId,
+      eventName,
+      eventSource,
+      eventSourceArn,
+      eventVersion,
+      userIdentity
+    )
+
+  private[events] implicit val decoder: Decoder[DynamoDbRecord] = Decoder.forProduct8(
     "awsRegion",
     "dynamodb",
     "eventID",
@@ -105,15 +186,37 @@ object DynamoDbRecord {
     "eventVersion",
     "userIdentity"
   )(DynamoDbRecord.apply)
+
+  private final case class Impl(
+      awsRegion: Option[String],
+      dynamodb: Option[StreamRecord],
+      eventId: Option[String],
+      eventName: Option[String],
+      eventSource: Option[String],
+      eventSourceArn: Option[String],
+      eventVersion: Option[String],
+      userIdentity: Option[Json]
+  ) extends DynamoDbRecord {
+    override def productPrefix = "DynamoDbRecord"
+  }
 }
 
-final case class DynamoDbStreamEvent(
-    records: List[DynamoDbRecord]
-)
+sealed abstract class DynamoDbStreamEvent {
+  def records: List[DynamoDbRecord]
+}
 
 object DynamoDbStreamEvent {
+  def apply(records: List[DynamoDbRecord]): DynamoDbStreamEvent =
+    new Impl(records)
+
   implicit val decoder: Decoder[DynamoDbStreamEvent] =
     Decoder.forProduct1("Records")(DynamoDbStreamEvent.apply)
 
   implicit def kernelSource: KernelSource[DynamoDbStreamEvent] = KernelSource.emptyKernelSource
+
+  private final case class Impl(
+      records: List[DynamoDbRecord]
+  ) extends DynamoDbStreamEvent {
+    override def productPrefix = "DynamoDbStreamEvent"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/KinesisStreamEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/KinesisStreamEvent.scala
index e8a8aaeb..a2736d7d 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/KinesisStreamEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/KinesisStreamEvent.scala
@@ -23,38 +23,85 @@ import scodec.bits.ByteVector
 
 import java.time.Instant
 
-final case class KinesisStreamRecordPayload(
-    approximateArrivalTimestamp: Instant,
-    data: ByteVector,
-    kinesisSchemaVersion: String,
-    partitionKey: String,
-    sequenceNumber: String
-)
+sealed abstract class KinesisStreamRecordPayload {
+  def approximateArrivalTimestamp: Instant
+  def data: ByteVector
+  def kinesisSchemaVersion: String
+  def partitionKey: String
+  def sequenceNumber: String
+}
 
 object KinesisStreamRecordPayload {
-  implicit private def instantDecoder: Decoder[Instant] = feral.lambda.events.instantDecoder
-  implicit val decoder: Decoder[KinesisStreamRecordPayload] = Decoder.forProduct5(
-    "approximateArrivalTimestamp",
-    "data",
-    "kinesisSchemaVersion",
-    "partitionKey",
-    "sequenceNumber"
-  )(KinesisStreamRecordPayload.apply)
+  def apply(
+      approximateArrivalTimestamp: Instant,
+      data: ByteVector,
+      kinesisSchemaVersion: String,
+      partitionKey: String,
+      sequenceNumber: String
+  ): KinesisStreamRecordPayload =
+    new Impl(
+      approximateArrivalTimestamp,
+      data,
+      kinesisSchemaVersion,
+      partitionKey,
+      sequenceNumber
+    )
+
+  import codecs.decodeInstant
+  private[events] implicit val decoder: Decoder[KinesisStreamRecordPayload] =
+    Decoder.forProduct5(
+      "approximateArrivalTimestamp",
+      "data",
+      "kinesisSchemaVersion",
+      "partitionKey",
+      "sequenceNumber"
+    )(KinesisStreamRecordPayload.apply)
+
+  private final case class Impl(
+      approximateArrivalTimestamp: Instant,
+      data: ByteVector,
+      kinesisSchemaVersion: String,
+      partitionKey: String,
+      sequenceNumber: String
+  ) extends KinesisStreamRecordPayload {
+    override def productPrefix = "KinesisStreamRecordPayload"
+  }
 }
 
-final case class KinesisStreamRecord(
-    awsRegion: String,
-    eventID: String,
-    eventName: String,
-    eventSource: String,
-    eventSourceArn: String,
-    eventVersion: String,
-    invokeIdentityArn: String,
-    kinesis: KinesisStreamRecordPayload
-)
+sealed abstract class KinesisStreamRecord {
+  def awsRegion: String
+  def eventId: String
+  def eventName: String
+  def eventSource: String
+  def eventSourceArn: String
+  def eventVersion: String
+  def invokeIdentityArn: String
+  def kinesis: KinesisStreamRecordPayload
+}
 
 object KinesisStreamRecord {
-  implicit val decoder: Decoder[KinesisStreamRecord] = Decoder.forProduct8(
+  def apply(
+      awsRegion: String,
+      eventId: String,
+      eventName: String,
+      eventSource: String,
+      eventSourceArn: String,
+      eventVersion: String,
+      invokeIdentityArn: String,
+      kinesis: KinesisStreamRecordPayload
+  ): KinesisStreamRecord =
+    new Impl(
+      awsRegion,
+      eventId,
+      eventName,
+      eventSource,
+      eventSourceArn,
+      eventVersion,
+      invokeIdentityArn,
+      kinesis
+    )
+
+  private[events] implicit val decoder: Decoder[KinesisStreamRecord] = Decoder.forProduct8(
     "awsRegion",
     "eventID",
     "eventName",
@@ -64,15 +111,37 @@ object KinesisStreamRecord {
     "invokeIdentityArn",
     "kinesis"
   )(KinesisStreamRecord.apply)
+
+  private final case class Impl(
+      awsRegion: String,
+      eventId: String,
+      eventName: String,
+      eventSource: String,
+      eventSourceArn: String,
+      eventVersion: String,
+      invokeIdentityArn: String,
+      kinesis: KinesisStreamRecordPayload
+  ) extends KinesisStreamRecord {
+    override def productPrefix = "KinesisStreamRecord"
+  }
 }
 
-final case class KinesisStreamEvent(
-    records: List[KinesisStreamRecord]
-)
+sealed abstract class KinesisStreamEvent {
+  def records: List[KinesisStreamRecord]
+}
 
 object KinesisStreamEvent {
+  def apply(records: List[KinesisStreamRecord]): KinesisStreamEvent =
+    new Impl(records)
+
   implicit val decoder: Decoder[KinesisStreamEvent] =
     Decoder.forProduct1("Records")(KinesisStreamEvent.apply)
 
   implicit def kernelSource: KernelSource[KinesisStreamEvent] = KernelSource.emptyKernelSource
+
+  private final case class Impl(
+      records: List[KinesisStreamRecord]
+  ) extends KinesisStreamEvent {
+    override def productPrefix = "KinesisStreamEvent"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/S3BatchEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/S3BatchEvent.scala
index b7c113e1..864889d6 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/S3BatchEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/S3BatchEvent.scala
@@ -21,35 +21,78 @@ import io.circe.Decoder
 
 // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/s3-batch.d.ts
 
-final case class S3BatchEvent(
-    invocationSchemaVersion: String,
-    invocationId: String,
-    job: S3BatchEventJob,
-    tasks: List[S3BatchEventTask]
-)
+sealed abstract class S3BatchEvent {
+  def invocationSchemaVersion: String
+  def invocationId: String
+  def job: S3BatchEventJob
+  def tasks: List[S3BatchEventTask]
+}
 
 object S3BatchEvent {
+  def apply(
+      invocationSchemaVersion: String,
+      invocationId: String,
+      job: S3BatchEventJob,
+      tasks: List[S3BatchEventTask]
+  ): S3BatchEvent =
+    new Impl(invocationSchemaVersion, invocationId, job, tasks)
+
   implicit val decoder: Decoder[S3BatchEvent] =
     Decoder.forProduct4("invocationSchemaVersion", "invocationId", "job", "tasks")(
       S3BatchEvent.apply)
 
   implicit def kernelSource: KernelSource[S3BatchEvent] = KernelSource.emptyKernelSource
+
+  private final case class Impl(
+      invocationSchemaVersion: String,
+      invocationId: String,
+      job: S3BatchEventJob,
+      tasks: List[S3BatchEventTask]
+  ) extends S3BatchEvent {
+    override def productPrefix = "S3BatchEvent"
+  }
 }
 
-final case class S3BatchEventJob(id: String)
+sealed abstract class S3BatchEventJob {
+  def id: String
+}
 
 object S3BatchEventJob {
-  implicit val decoder: Decoder[S3BatchEventJob] =
+  def apply(id: String): S3BatchEventJob = new Impl(id)
+
+  private[events] implicit val decoder: Decoder[S3BatchEventJob] =
     Decoder.forProduct1("id")(S3BatchEventJob.apply)
+
+  private final case class Impl(id: String) extends S3BatchEventJob {
+    override def productPrefix = "S3BatchEventJob"
+  }
 }
 
-final case class S3BatchEventTask(
-    taskId: String,
-    s3Key: String,
-    s3VersionId: Option[String],
-    s3BucketArn: String)
+sealed abstract class S3BatchEventTask {
+  def taskId: String
+  def s3Key: String
+  def s3VersionId: Option[String]
+  def s3BucketArn: String
+}
 
 object S3BatchEventTask {
-  implicit val decoder: Decoder[S3BatchEventTask] =
+  def apply(
+      taskId: String,
+      s3Key: String,
+      s3VersionId: Option[String],
+      s3BucketArn: String
+  ): S3BatchEventTask =
+    new Impl(taskId, s3Key, s3VersionId, s3BucketArn)
+
+  private[events] implicit val decoder: Decoder[S3BatchEventTask] =
     Decoder.forProduct4("taskId", "s3Key", "s3VersionId", "s3BucketArn")(S3BatchEventTask.apply)
+
+  private final case class Impl(
+      taskId: String,
+      s3Key: String,
+      s3VersionId: Option[String],
+      s3BucketArn: String)
+      extends S3BatchEventTask {
+    override def productPrefix = "S3BatchEventTask"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/S3BatchResult.scala b/lambda/shared/src/main/scala/feral/lambda/events/S3BatchResult.scala
index c92b29a3..92f1f383 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/S3BatchResult.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/S3BatchResult.scala
@@ -20,14 +20,22 @@ import io.circe.Encoder
 
 // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/s3-batch.d.ts
 
-final case class S3BatchResult(
-    invocationSchemaVersion: String,
-    treatMissingKeysAs: S3BatchResultResultCode,
-    invocationId: String,
-    results: List[S3BatchResultResult]
-)
+sealed abstract class S3BatchResult {
+  def invocationSchemaVersion: String
+  def treatMissingKeysAs: S3BatchResultResultCode
+  def invocationId: String
+  def results: List[S3BatchResultResult]
+}
 
 object S3BatchResult {
+  def apply(
+      invocationSchemaVersion: String,
+      treatMissingKeysAs: S3BatchResultResultCode,
+      invocationId: String,
+      results: List[S3BatchResultResult]
+  ): S3BatchResult =
+    new Impl(invocationSchemaVersion, treatMissingKeysAs, invocationId, results)
+
   implicit val encoder: Encoder[S3BatchResult] =
     Encoder.forProduct4(
       "invocationSchemaVersion",
@@ -35,6 +43,15 @@ object S3BatchResult {
       "invocationId",
       "results")(r =>
       (r.invocationSchemaVersion, r.treatMissingKeysAs, r.invocationId, r.results))
+
+  private final case class Impl(
+      invocationSchemaVersion: String,
+      treatMissingKeysAs: S3BatchResultResultCode,
+      invocationId: String,
+      results: List[S3BatchResultResult]
+  ) extends S3BatchResult {
+    override def productPrefix = "S3BatchResult"
+  }
 }
 
 sealed abstract class S3BatchResultResultCode
@@ -44,20 +61,37 @@ object S3BatchResultResultCode {
   case object TemporaryFailure extends S3BatchResultResultCode
   case object PermanentFailure extends S3BatchResultResultCode
 
-  implicit val encoder: Encoder[S3BatchResultResultCode] = Encoder.encodeString.contramap {
-    case Succeeded => "Succeeded"
-    case TemporaryFailure => "TemporaryFailure"
-    case PermanentFailure => "PermanentFailure"
-  }
+  private[events] implicit val encoder: Encoder[S3BatchResultResultCode] =
+    Encoder.encodeString.contramap {
+      case Succeeded => "Succeeded"
+      case TemporaryFailure => "TemporaryFailure"
+      case PermanentFailure => "PermanentFailure"
+    }
 }
 
-final case class S3BatchResultResult(
-    taskId: String,
-    resultCode: S3BatchResultResultCode,
-    resultString: String)
+sealed abstract class S3BatchResultResult {
+  def taskId: String
+  def resultCode: S3BatchResultResultCode
+  def resultString: String
+}
 
 object S3BatchResultResult {
-  implicit val encoder: Encoder[S3BatchResultResult] =
+  def apply(
+      taskId: String,
+      resultCode: S3BatchResultResultCode,
+      resultString: String
+  ): S3BatchResultResult =
+    new Impl(taskId, resultCode, resultString)
+
+  private[events] implicit val encoder: Encoder[S3BatchResultResult] =
     Encoder.forProduct3("taskId", "resultCode", "resultString")(r =>
       (r.taskId, r.resultCode, r.resultString))
+
+  private final case class Impl(
+      taskId: String,
+      resultCode: S3BatchResultResultCode,
+      resultString: String)
+      extends S3BatchResultResult {
+    override def productPrefix = "S3BatchResultResult"
+  }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/S3Event.scala b/lambda/shared/src/main/scala/feral/lambda/events/S3Event.scala
index 3a0ebd8b..57db9a1f 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/S3Event.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/S3Event.scala
@@ -16,33 +16,69 @@
 
 package feral.lambda.events
 
+import com.comcast.ip4s.IpAddress
 import io.circe.Decoder
 
 import java.time.Instant
 
 // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/s3.d.ts
 
-final case class S3Event(records: List[S3EventRecord])
+sealed abstract class S3Event {
+  def records: List[S3EventRecord]
+}
 
 object S3Event {
+  def apply(records: List[S3EventRecord]): S3Event =
+    new Impl(records)
+
   implicit val decoder: Decoder[S3Event] = Decoder.forProduct1("Records")(S3Event.apply)
+
+  private final case class Impl(records: List[S3EventRecord]) extends S3Event {
+    override def productPrefix = "S3Event"
+  }
 }
 
-final case class S3EventRecord(
-    eventVersion: String,
-    eventSource: String,
-    awsRegion: String,
-    eventTime: Instant,
-    eventName: String,
-    userIdentity: S3UserIdentity,
-    requestParameters: S3RequestParameters,
-    responseElements: S3ResponseElements,
-    s3: S3,
-    glacierEventData: Option[S3EventRecordGlacierEventData])
+sealed abstract class S3EventRecord {
+  def eventVersion: String
+  def eventSource: String
+  def awsRegion: String
+  def eventTime: Instant
+  def eventName: String
+  def userIdentity: S3UserIdentity
+  def requestParameters: S3RequestParameters
+  def responseElements: S3ResponseElements
+  def s3: S3
+  def glacierEventData: Option[S3EventRecordGlacierEventData]
+}
 
 object S3EventRecord {
 
-  implicit val decoder: Decoder[S3EventRecord] =
+  def apply(
+      eventVersion: String,
+      eventSource: String,
+      awsRegion: String,
+      eventTime: Instant,
+      eventName: String,
+      userIdentity: S3UserIdentity,
+      requestParameters: S3RequestParameters,
+      responseElements: S3ResponseElements,
+      s3: S3,
+      glacierEventData: Option[S3EventRecordGlacierEventData]
+  ): S3EventRecord =
+    new Impl(
+      eventVersion,
+      eventSource,
+      awsRegion,
+      eventTime,
+      eventName,
+      userIdentity,
+      requestParameters,
+      responseElements,
+      s3,
+      glacierEventData
+    )
+
+  private[events] implicit val decoder: Decoder[S3EventRecord] =
     Decoder.forProduct10(
       "eventVersion",
       "eventSource",
@@ -56,90 +92,200 @@ object S3EventRecord {
       "glacierEventData"
     )(S3EventRecord.apply)
 
+  private final case class Impl(
+      eventVersion: String,
+      eventSource: String,
+      awsRegion: String,
+      eventTime: Instant,
+      eventName: String,
+      userIdentity: S3UserIdentity,
+      requestParameters: S3RequestParameters,
+      responseElements: S3ResponseElements,
+      s3: S3,
+      glacierEventData: Option[S3EventRecordGlacierEventData])
+      extends S3EventRecord {
+    override def productPrefix = "S3EventRecord"
+  }
 }
 
-final case class S3UserIdentity(principalId: String)
+sealed abstract class S3UserIdentity { def principalId: String }
 
 object S3UserIdentity {
 
-  implicit val decoder: Decoder[S3UserIdentity] =
+  def apply(principalId: String): S3UserIdentity =
+    new Impl(principalId)
+
+  private[events] implicit val decoder: Decoder[S3UserIdentity] =
     Decoder.forProduct1("principalId")(S3UserIdentity.apply)
 
+  private final case class Impl(principalId: String) extends S3UserIdentity {
+    override def productPrefix = "S3UserIdentity"
+  }
 }
 
-final case class S3RequestParameters(sourceIPAddress: String)
+sealed abstract class S3RequestParameters {
+  def sourceIpAddress: IpAddress
+}
 
 object S3RequestParameters {
 
-  implicit val decoder: Decoder[S3RequestParameters] =
+  def apply(sourceIpAddress: IpAddress): S3RequestParameters =
+    new Impl(sourceIpAddress)
+
+  import codecs.decodeIpAddress
+  private[events] implicit val decoder: Decoder[S3RequestParameters] =
     Decoder.forProduct1("sourceIPAddress")(S3RequestParameters.apply)
 
+  private final case class Impl(sourceIpAddress: IpAddress) extends S3RequestParameters {
+    override def productPrefix = "S3RequestParameters"
+  }
 }
 
-final case class S3ResponseElements(`x-amz-request-id`: String, `x-amz-id-2`: String)
+sealed abstract class S3ResponseElements {
+  def `x-amz-request-id`: String
+  def `x-amz-id-2`: String
+}
 
 object S3ResponseElements {
 
-  implicit val decoder: Decoder[S3ResponseElements] =
+  def apply(`x-amz-request-id`: String, `x-amz-id-2`: String): S3ResponseElements =
+    new Impl(`x-amz-request-id`, `x-amz-id-2`)
+
+  private[events] implicit val decoder: Decoder[S3ResponseElements] =
     Decoder.forProduct2("x-amz-request-id", "x-amz-id-2")(S3ResponseElements.apply)
 
+  private final case class Impl(`x-amz-request-id`: String, `x-amz-id-2`: String)
+      extends S3ResponseElements {
+    override def productPrefix = "S3ResponseElements"
+  }
+
 }
 
-final case class S3(
-    s3SchemaVersion: String,
-    configurationId: String,
-    bucket: S3Bucket,
-    `object`: S3Object)
+sealed abstract class S3 {
+  def s3SchemaVersion: String
+  def configurationId: String
+  def bucket: S3Bucket
+  def `object`: S3Object
+}
 
 object S3 {
 
-  implicit val decoder: Decoder[S3] =
+  def apply(
+      s3SchemaVersion: String,
+      configurationId: String,
+      bucket: S3Bucket,
+      `object`: S3Object
+  ): S3 =
+    new Impl(s3SchemaVersion, configurationId, bucket, `object`)
+
+  private[events] implicit val decoder: Decoder[S3] =
     Decoder.forProduct4("s3SchemaVersion", "configurationId", "bucket", "object")(S3.apply)
 
+  private final case class Impl(
+      s3SchemaVersion: String,
+      configurationId: String,
+      bucket: S3Bucket,
+      `object`: S3Object)
+      extends S3 {
+    override def productPrefix = "S3"
+  }
 }
 
-final case class S3Bucket(name: String, ownerIdentity: S3UserIdentity, arn: String)
+sealed abstract class S3Bucket {
+  def name: String
+  def ownerIdentity: S3UserIdentity
+  def arn: String
+}
 
 object S3Bucket {
 
-  implicit val decoder: Decoder[S3Bucket] =
+  def apply(name: String, ownerIdentity: S3UserIdentity, arn: String): S3Bucket =
+    new Impl(name, ownerIdentity, arn)
+
+  private[events] implicit val decoder: Decoder[S3Bucket] =
     Decoder.forProduct3("name", "ownerIdentity", "arn")(S3Bucket.apply)
 
+  private final case class Impl(name: String, ownerIdentity: S3UserIdentity, arn: String)
+      extends S3Bucket {
+    override def productPrefix = "S3Bucket"
+  }
 }
 
-final case class S3Object(
-    key: String,
-    size: Long,
-    eTag: String,
-    versionId: Option[String],
-    sequencer: String)
+sealed abstract class S3Object {
+  def key: String
+  def size: Long
+  def eTag: String
+  def versionId: Option[String]
+  def sequencer: String
+}
 
 object S3Object {
 
-  implicit val decoder: Decoder[S3Object] =
+  def apply(
+      key: String,
+      size: Long,
+      eTag: String,
+      versionId: Option[String],
+      sequencer: String
+  ): S3Object =
+    new Impl(key, size, eTag, versionId, sequencer)
+
+  private[events] implicit val decoder: Decoder[S3Object] =
     Decoder.forProduct5("key", "size", "eTag", "versionId", "sequencer")(S3Object.apply)
 
+  private final case class Impl(
+      key: String,
+      size: Long,
+      eTag: String,
+      versionId: Option[String],
+      sequencer: String)
+      extends S3Object {
+    override def productPrefix = "S3Object"
+  }
 }
 
-final case class S3EventRecordGlacierEventData(
-    restoreEventData: S3EventRecordGlacierRestoreEventData)
+sealed abstract class S3EventRecordGlacierEventData {
+  def restoreEventData: S3EventRecordGlacierRestoreEventData
+}
 
 object S3EventRecordGlacierEventData {
 
-  implicit val decoder: Decoder[S3EventRecordGlacierEventData] =
+  def apply(
+      restoreEventData: S3EventRecordGlacierRestoreEventData): S3EventRecordGlacierEventData =
+    new Impl(restoreEventData)
+
+  private[events] implicit val decoder: Decoder[S3EventRecordGlacierEventData] =
     Decoder.forProduct1("restoreEventData")(S3EventRecordGlacierEventData.apply)
 
+  private final case class Impl(restoreEventData: S3EventRecordGlacierRestoreEventData)
+      extends S3EventRecordGlacierEventData {
+    override def productPrefix = "S3EventRecordGlacierEventData"
+  }
 }
 
-final case class S3EventRecordGlacierRestoreEventData(
-    lifecycleRestorationExpiryTime: Instant,
-    lifecycleRestoreStorageClass: String)
+sealed abstract class S3EventRecordGlacierRestoreEventData {
+  def lifecycleRestorationExpiryTime: Instant
+  def lifecycleRestoreStorageClass: String
+}
 
 object S3EventRecordGlacierRestoreEventData {
 
-  implicit val decoder: Decoder[S3EventRecordGlacierRestoreEventData] =
+  def apply(
+      lifecycleRestorationExpiryTime: Instant,
+      lifecycleRestoreStorageClass: String
+  ): S3EventRecordGlacierRestoreEventData =
+    new Impl(lifecycleRestorationExpiryTime, lifecycleRestoreStorageClass)
+
+  private[events] implicit val decoder: Decoder[S3EventRecordGlacierRestoreEventData] =
     Decoder.forProduct2("lifecycleRestorationExpiryTime", "lifecycleRestoreStorageClass")(
       S3EventRecordGlacierRestoreEventData.apply
     )
 
+  private final case class Impl(
+      lifecycleRestorationExpiryTime: Instant,
+      lifecycleRestoreStorageClass: String
+  ) extends S3EventRecordGlacierRestoreEventData {
+    override def productPrefix = "S3EventRecordGlacierRestoreEventData"
+  }
+
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/SnsEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/SnsEvent.scala
index e21a8af7..ec197e7e 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/SnsEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/SnsEvent.scala
@@ -26,49 +26,101 @@ import scodec.bits.ByteVector
 import java.time.Instant
 import java.util.UUID
 
-final case class SnsEvent(
-    records: List[SnsEventRecord]
-)
+sealed abstract class SnsEvent {
+  def records: List[SnsEventRecord]
+}
 
 object SnsEvent {
+  def apply(records: List[SnsEventRecord]): SnsEvent =
+    new Impl(records)
+
   implicit val decoder: Decoder[SnsEvent] =
     Decoder.forProduct1("Records")(SnsEvent.apply)
+
+  private final case class Impl(
+      records: List[SnsEventRecord]
+  ) extends SnsEvent {
+    override def productPrefix = "SnsEvent"
+  }
 }
 
-final case class SnsEventRecord(
-    eventVersion: String,
-    eventSubscriptionArn: String,
-    eventSource: String,
-    sns: SnsMessage
-)
+sealed abstract class SnsEventRecord {
+  def eventVersion: String
+  def eventSubscriptionArn: String
+  def eventSource: String
+  def sns: SnsMessage
+}
 
 object SnsEventRecord {
-  implicit val decoder: Decoder[SnsEventRecord] = Decoder.forProduct4(
+  def apply(
+      eventVersion: String,
+      eventSubscriptionArn: String,
+      eventSource: String,
+      sns: SnsMessage
+  ): SnsEventRecord =
+    new Impl(eventVersion, eventSubscriptionArn, eventSource, sns)
+
+  private[events] implicit val decoder: Decoder[SnsEventRecord] = Decoder.forProduct4(
     "EventVersion",
     "EventSubscriptionArn",
     "EventSource",
     "Sns"
   )(SnsEventRecord.apply)
+
+  private final case class Impl(
+      eventVersion: String,
+      eventSubscriptionArn: String,
+      eventSource: String,
+      sns: SnsMessage
+  ) extends SnsEventRecord {
+    override def productPrefix = "SnsEventRecord"
+  }
 }
 
-final case class SnsMessage(
-    signature: String,
-    messageId: UUID,
-    `type`: String,
-    topicArn: String,
-    messageAttributes: Map[String, SnsMessageAttribute],
-    signatureVersion: String,
-    timestamp: Instant,
-    signingCertUrl: String,
-    message: String,
-    unsubscribeUrl: String,
-    subject: Option[String]
-)
+sealed abstract class SnsMessage {
+  def signature: String
+  def messageId: UUID
+  def `type`: String
+  def topicArn: String
+  def messageAttributes: Map[String, SnsMessageAttribute]
+  def signatureVersion: String
+  def timestamp: Instant
+  def signingCertUrl: String
+  def message: String
+  def unsubscribeUrl: String
+  def subject: Option[String]
+}
 
 object SnsMessage {
-  private[this] implicit val instantDecoder: Decoder[Instant] = Decoder.decodeInstant
 
-  implicit val decoder: Decoder[SnsMessage] = Decoder.forProduct11(
+  def apply(
+      signature: String,
+      messageId: UUID,
+      `type`: String,
+      topicArn: String,
+      messageAttributes: Map[String, SnsMessageAttribute],
+      signatureVersion: String,
+      timestamp: Instant,
+      signingCertUrl: String,
+      message: String,
+      unsubscribeUrl: String,
+      subject: Option[String]
+  ): SnsMessage =
+    new Impl(
+      signature,
+      messageId,
+      `type`,
+      topicArn,
+      messageAttributes,
+      signatureVersion,
+      timestamp,
+      signingCertUrl,
+      message,
+      unsubscribeUrl,
+      subject
+    )
+
+  private[events] implicit val decoder: Decoder[SnsMessage] = Decoder.forProduct11(
     "Signature",
     "MessageId",
     "Type",
@@ -81,6 +133,22 @@ object SnsMessage {
     "UnsubscribeUrl",
     "Subject"
   )(SnsMessage.apply)
+
+  private final case class Impl(
+      signature: String,
+      messageId: UUID,
+      `type`: String,
+      topicArn: String,
+      messageAttributes: Map[String, SnsMessageAttribute],
+      signatureVersion: String,
+      timestamp: Instant,
+      signingCertUrl: String,
+      message: String,
+      unsubscribeUrl: String,
+      subject: Option[String]
+  ) extends SnsMessage {
+    override def productPrefix = "SnsMessage"
+  }
 }
 
 sealed abstract class SnsMessageAttribute
@@ -96,7 +164,7 @@ object SnsMessageAttribute {
       value: Option[Predef.String]
   ) extends SnsMessageAttribute
 
-  implicit val decoder: Decoder[SnsMessageAttribute] = {
+  private[events] implicit val decoder: Decoder[SnsMessageAttribute] = {
     val getString: Decoder[Predef.String] = Decoder.instance(_.get[Predef.String]("Value"))
     val getByteVector: Decoder[ByteVector] = Decoder.instance(_.get[ByteVector]("Value"))
     val getNumber: Decoder[BigDecimal] = Decoder.instance(_.get[BigDecimal]("Value"))
@@ -134,7 +202,7 @@ object SnsMessageAttributeArrayMember {
   final case class Number(value: BigDecimal) extends SnsMessageAttributeArrayMember
   final case class Boolean(value: scala.Boolean) extends SnsMessageAttributeArrayMember
 
-  implicit val decoder: Decoder[SnsMessageAttributeArrayMember] = {
+  private[events] implicit val decoder: Decoder[SnsMessageAttributeArrayMember] = {
     val bool: Decoder[SnsMessageAttributeArrayMember.Boolean] =
       Decoder.decodeBoolean.map(SnsMessageAttributeArrayMember.Boolean.apply)
 
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/SqsEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/SqsEvent.scala
index 70a0bec8..ec5f0893 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/SqsEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/SqsEvent.scala
@@ -29,29 +29,59 @@ import scala.util.Try
 // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/trigger/Sqs.d.ts
 // https://docs.aws.amazon.com/lambda/latest/dg/invoking-lambda-function.html#supported-event-source-Sqs
 
-final case class SqsEvent(
-    records: List[SqsRecord]
-)
+sealed abstract class SqsEvent {
+  def records: List[SqsRecord]
+}
 
 object SqsEvent {
+  def apply(records: List[SqsRecord]): SqsEvent =
+    new Impl(records)
+
   implicit val decoder: Decoder[SqsEvent] =
     Decoder.instance(_.get[List[SqsRecord]]("Records")).map(SqsEvent(_))
+
+  private final case class Impl(records: List[SqsRecord]) extends SqsEvent {
+    override def productPrefix = "SqsEvent"
+  }
 }
 
-final case class SqsRecord(
-    messageId: String,
-    receiptHandle: String,
-    body: String,
-    attributes: SqsRecordAttributes,
-    messageAttributes: Map[String, SqsMessageAttribute],
-    md5OfBody: String,
-    eventSource: String,
-    eventSourceArn: String,
-    awsRegion: String
-)
+sealed abstract class SqsRecord {
+  def messageId: String
+  def receiptHandle: String
+  def body: String
+  def attributes: SqsRecordAttributes
+  def messageAttributes: Map[String, SqsMessageAttribute]
+  def md5OfBody: String
+  def eventSource: String
+  def eventSourceArn: String
+  def awsRegion: String
+}
 
 object SqsRecord {
-  implicit val decoder: Decoder[SqsRecord] = Decoder.instance(i =>
+  def apply(
+      messageId: String,
+      receiptHandle: String,
+      body: String,
+      attributes: SqsRecordAttributes,
+      messageAttributes: Map[String, SqsMessageAttribute],
+      md5OfBody: String,
+      eventSource: String,
+      eventSourceArn: String,
+      awsRegion: String
+  ): SqsRecord =
+    new Impl(
+      messageId,
+      receiptHandle,
+      body,
+      attributes,
+      messageAttributes,
+      md5OfBody,
+      eventSource,
+      eventSourceArn,
+      awsRegion
+    )
+
+  private[events] implicit val decoder: Decoder[SqsRecord] = Decoder.instance(i =>
     for {
       messageId <- i.get[String]("messageId")
       receiptHandle <- i.get[String]("receiptHandle")
@@ -73,24 +103,58 @@ object SqsRecord {
       eventSourceArn,
       awsRegion
     ))
+
+  private final case class Impl(
+      messageId: String,
+      receiptHandle: String,
+      body: String,
+      attributes: SqsRecordAttributes,
+      messageAttributes: Map[String, SqsMessageAttribute],
+      md5OfBody: String,
+      eventSource: String,
+      eventSourceArn: String,
+      awsRegion: String
+  ) extends SqsRecord {
+    override def productPrefix = "SqsRecord"
+  }
 }
 
-final case class SqsRecordAttributes(
-    awsTraceHeader: Option[String],
-    approximateReceiveCount: String,
-    sentTimestamp: Instant,
-    senderId: String,
-    approximateFirstReceiveTimestamp: Instant,
-    sequenceNumber: Option[String],
-    messageGroupId: Option[String],
-    messageDeduplicationId: Option[String]
-)
+sealed abstract class SqsRecordAttributes {
+  def awsTraceHeader: Option[String]
+  def approximateReceiveCount: String
+  def sentTimestamp: Instant
+  def senderId: String
+  def approximateFirstReceiveTimestamp: Instant
+  def sequenceNumber: Option[String]
+  def messageGroupId: Option[String]
+  def messageDeduplicationId: Option[String]
+}
 
 object SqsRecordAttributes {
 
-  implicit private def instantDecoder: Decoder[Instant] = feral.lambda.events.instantDecoder
+  def apply(
+      awsTraceHeader: Option[String],
+      approximateReceiveCount: String,
+      sentTimestamp: Instant,
+      senderId: String,
+      approximateFirstReceiveTimestamp: Instant,
+      sequenceNumber: Option[String],
+      messageGroupId: Option[String],
+      messageDeduplicationId: Option[String]
+  ): SqsRecordAttributes =
+    new Impl(
+      awsTraceHeader,
+      approximateReceiveCount,
+      sentTimestamp,
+      senderId,
+      approximateFirstReceiveTimestamp,
+      sequenceNumber,
+      messageGroupId,
+      messageDeduplicationId
+    )
 
-  implicit val decoder: Decoder[SqsRecordAttributes] = Decoder.instance(i =>
+  private[events] implicit val decoder: Decoder[SqsRecordAttributes] = Decoder.instance { i =>
+    import codecs.decodeInstant
     for {
       awsTraceHeader <- i.get[Option[String]]("AWSTraceHeader")
       approximateReceiveCount <- i.get[String]("ApproximateReceiveCount")
@@ -109,11 +173,25 @@ object SqsRecordAttributes {
       sequenceNumber,
       messageGroupId,
       messageDeduplicationId
-    ))
+    )
+  }
 
   implicit def kernelSource: KernelSource[SqsRecordAttributes] = a =>
     Kernel(a.awsTraceHeader.map(`X-Amzn-Trace-Id` -> _).toMap)
 
+  private final case class Impl(
+      awsTraceHeader: Option[String],
+      approximateReceiveCount: String,
+      sentTimestamp: Instant,
+      senderId: String,
+      approximateFirstReceiveTimestamp: Instant,
+      sequenceNumber: Option[String],
+      messageGroupId: Option[String],
+      messageDeduplicationId: Option[String]
+  ) extends SqsRecordAttributes {
+    override def productPrefix = "SqsRecordAttributes"
+  }
+
   private[this] val `X-Amzn-Trace-Id` = ci"X-Amzn-Trace-Id"
 }
 
@@ -129,7 +207,7 @@ object SqsMessageAttribute {
       dataType: Predef.String
   ) extends SqsMessageAttribute
 
-  implicit val decoder: Decoder[SqsMessageAttribute] = {
+  private[events] implicit val decoder: Decoder[SqsMessageAttribute] = {
     val strValue =
       Decoder.instance(_.get[Predef.String]("stringValue"))
 
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/package.scala b/lambda/shared/src/main/scala/feral/lambda/events/codecs.scala
similarity index 62%
rename from lambda/shared/src/main/scala/feral/lambda/events/package.scala
rename to lambda/shared/src/main/scala/feral/lambda/events/codecs.scala
index 328e159c..cee02ea1 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/package.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/codecs.scala
@@ -14,16 +14,20 @@
  * limitations under the License.
  */
 
-package feral.lambda
+package feral.lambda.events
 
+import com.comcast.ip4s.IpAddress
 import io.circe.Decoder
+import io.circe.KeyDecoder
+import io.circe.KeyEncoder
+import org.typelevel.ci.CIString
 
 import java.time.Instant
 import scala.util.Try
 
-package object events {
+private object codecs {
 
-  lazy val instantDecoder: Decoder[Instant] =
+  implicit def decodeInstant: Decoder[Instant] =
     Decoder.decodeBigDecimal.emapTry { millis =>
       def round(x: BigDecimal) = x.setScale(0, BigDecimal.RoundingMode.DOWN)
       Try {
@@ -33,4 +37,13 @@ package object events {
       }
     }
 
+  implicit def decodeIpAddress: Decoder[IpAddress] =
+    Decoder.decodeString.emap(IpAddress.fromString(_).toRight("Cannot parse IP address"))
+
+  implicit def decodeKeyCIString: KeyDecoder[CIString] =
+    KeyDecoder.decodeKeyString.map(CIString(_))
+
+  implicit def encodeKeyCIString: KeyEncoder[CIString] =
+    KeyEncoder.encodeKeyString.contramap(_.toString)
+
 }
diff --git a/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala b/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala
index 035bf0b7..fbd41b0c 100644
--- a/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala
+++ b/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala
@@ -46,6 +46,6 @@ class IOLambdaSuite extends CatsEffectSuite {
     } yield ()
   }
 
-  def mockContext = new Context[IO]("", "", "", 0, "", "", "", None, None, IO.never)
+  def mockContext = Context[IO]("", "", "", 0, "", "", "", None, None, IO.stub)
 
 }
diff --git a/lambda/shared/src/test/scala/feral/lambda/events/APIGatewayProxyEventSuite.scala b/lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayProxyEventSuite.scala
similarity index 96%
rename from lambda/shared/src/test/scala/feral/lambda/events/APIGatewayProxyEventSuite.scala
rename to lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayProxyEventSuite.scala
index 4ee63f9b..1ff531f7 100644
--- a/lambda/shared/src/test/scala/feral/lambda/events/APIGatewayProxyEventSuite.scala
+++ b/lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayProxyEventSuite.scala
@@ -19,17 +19,17 @@ package feral.lambda.events
 import io.circe.literal._
 import munit.FunSuite
 
-class APIGatewayProxyEventSuite extends FunSuite {
+class ApiGatewayProxyEventSuite extends FunSuite {
 
-  import APIGatewayProxyEventSuite._
+  import ApiGatewayProxyEventSuite._
 
   test("decoder") {
-    event.as[APIGatewayProxyRequestEvent].toTry.get
+    event.as[ApiGatewayProxyEvent].toTry.get
   }
 
 }
 
-object APIGatewayProxyEventSuite {
+object ApiGatewayProxyEventSuite {
 
   def event = json"""
   {
diff --git a/lambda/shared/src/test/scala/feral/lambda/events/InstantDecoderSuite.scala b/lambda/shared/src/test/scala/feral/lambda/events/InstantDecoderSuite.scala
index 552124a8..deed16e4 100644
--- a/lambda/shared/src/test/scala/feral/lambda/events/InstantDecoderSuite.scala
+++ b/lambda/shared/src/test/scala/feral/lambda/events/InstantDecoderSuite.scala
@@ -16,7 +16,7 @@
 
 package feral.lambda.events
 
-import io.circe.{Decoder, Json}
+import io.circe.Json
 import munit.ScalaCheckSuite
 import org.scalacheck.Arbitrary
 import org.scalacheck.Gen
@@ -26,7 +26,7 @@ import java.time.Instant
 
 class InstantDecoderSuite extends ScalaCheckSuite {
 
-  implicit private def instantDecoder: Decoder[Instant] = feral.lambda.events.instantDecoder
+  import codecs.decodeInstant
 
   implicit val arbitraryInstant: Arbitrary[Instant] = Arbitrary(
     Gen.long.map(Instant.ofEpochMilli(_)))
diff --git a/lambda/shared/src/test/scala/feral/lambda/events/S3EventSuite.scala b/lambda/shared/src/test/scala/feral/lambda/events/S3EventSuite.scala
index 3861dd69..cb9836a3 100644
--- a/lambda/shared/src/test/scala/feral/lambda/events/S3EventSuite.scala
+++ b/lambda/shared/src/test/scala/feral/lambda/events/S3EventSuite.scala
@@ -16,6 +16,7 @@
 
 package feral.lambda.events
 
+import com.comcast.ip4s._
 import io.circe.literal._
 import munit.FunSuite
 
@@ -77,7 +78,7 @@ class S3EventSuite extends FunSuite {
       eventTime = Instant.ofEpochSecond(0),
       eventName = "ObjectCreated:Put",
       userIdentity = S3UserIdentity("AIDAJDPLRKLG7UEXAMPLE"),
-      requestParameters = S3RequestParameters("127.0.0.1"),
+      requestParameters = S3RequestParameters(ip"127.0.0.1"),
       responseElements = S3ResponseElements(
         "C3D13FE58DE4C810",
         "FMyUVURIY8/IgAtTv8xRjskZQpcIZ9KG4V5Wp6S7S/JRWeUWerMUE5JgHvANOjpD"

From b3d51d9bb12cac8da561e09ccf8611bfe0d433b3 Mon Sep 17 00:00:00 2001
From: Arman Bilge <armanbilge@gmail.com>
Date: Mon, 20 Nov 2023 22:29:41 +0000
Subject: [PATCH 2/3] Formatting

---
 .../scala/feral/lambda/events/ApiGatewayProxyResult.scala    | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala
index 5129c02e..5654538c 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyResult.scala
@@ -26,10 +26,7 @@ sealed abstract class ApiGatewayProxyResult {
 
 object ApiGatewayProxyResult {
 
-  def apply(
-      statusCode: Int,
-      body: String,
-      isBase64Encoded: Boolean): ApiGatewayProxyResult =
+  def apply(statusCode: Int, body: String, isBase64Encoded: Boolean): ApiGatewayProxyResult =
     new Impl(statusCode, body, isBase64Encoded)
 
   implicit def encoder: Encoder[ApiGatewayProxyResult] = Encoder.forProduct3(

From 0fde43e13a4ee3bc15fe190a9926dcb4d0957a08 Mon Sep 17 00:00:00 2001
From: Arman Bilge <armanbilge@gmail.com>
Date: Tue, 21 Nov 2023 00:28:25 +0000
Subject: [PATCH 3/3] Fixups

---
 lambda/shared/src/main/scala/feral/lambda/Context.scala       | 4 +++-
 .../scala/feral/lambda/events/ApiGatewayProxyEventV2.scala    | 2 +-
 .../main/scala/feral/lambda/events/DynamoDbStreamEvent.scala  | 2 +-
 3 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/lambda/shared/src/main/scala/feral/lambda/Context.scala b/lambda/shared/src/main/scala/feral/lambda/Context.scala
index dcdf6d19..5dd9e9e1 100644
--- a/lambda/shared/src/main/scala/feral/lambda/Context.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/Context.scala
@@ -128,7 +128,9 @@ object ClientContext {
       client: ClientContextClient,
       env: ClientContextEnv,
       custom: JsonObject
-  ) extends ClientContext
+  ) extends ClientContext {
+    override def productPrefix = "ClientContext"
+  }
 }
 
 sealed abstract class ClientContextClient {
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala
index b805c22e..83017a91 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayProxyEventV2.scala
@@ -48,7 +48,7 @@ object RequestContext {
   private[events] implicit val decoder: Decoder[RequestContext] =
     Decoder.forProduct1("http")(RequestContext.apply)
 
-  final case class Impl(http: Http) extends RequestContext {
+  private final case class Impl(http: Http) extends RequestContext {
     override def productPrefix = "RequestContext"
   }
 }
diff --git a/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala
index 9ec412ee..f7483ac8 100644
--- a/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala
+++ b/lambda/shared/src/main/scala/feral/lambda/events/DynamoDbStreamEvent.scala
@@ -130,7 +130,7 @@ object StreamRecord {
     "StreamViewType"
   )(StreamRecord.apply)
 
-  final case class Impl(
+  private final case class Impl(
       approximateCreationDateTime: Option[Double],
       keys: Option[Map[String, AttributeValue]],
       newImage: Option[Map[String, AttributeValue]],