diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc29a739..a0a1a46a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target core/.js/target lambda-http4s/.js/target core/.jvm/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target + run: mkdir -p lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target lambda-http4s/.js/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target core/.js/target lambda-http4s/.js/target core/.jvm/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target + run: tar cf targets.tar lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target lambda-http4s/.js/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/build.sbt b/build.sbt index a6118df9..76e8ba15 100644 --- a/build.sbt +++ b/build.sbt @@ -66,7 +66,6 @@ lazy val commonSettings = Seq( lazy val root = tlCrossRootProject .aggregate( - core, lambda, lambdaHttp4s, lambdaCloudFormationCustomResource, @@ -83,22 +82,12 @@ lazy val rootSbtScalafix = project .aggregate(scalafix.componentProjectReferences: _*) .enablePlugins(NoPublishPlugin) -lazy val core = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) - .in(file("core")) - .settings( - name := "feral-core", - libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-effect" % catsEffectVersion - ) - ) - .settings(commonSettings) - lazy val lambda = crossProject(JSPlatform, JVMPlatform) .in(file("lambda")) .settings( name := "feral-lambda", libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % catsEffectVersion, "org.tpolecat" %%% "natchez-core" % natchezVersion, "io.circe" %%% "circe-scodec" % circeVersion, "io.circe" %%% "circe-jawn" % circeVersion, @@ -125,7 +114,6 @@ lazy val lambda = crossProject(JSPlatform, JVMPlatform) "co.fs2" %%% "fs2-io" % fs2Version ) ) - .dependsOn(core) lazy val sbtLambda = project .in(file("sbt-lambda")) @@ -140,7 +128,7 @@ lazy val sbtLambda = project scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq("-Xmx1024M", "-Dplugin.version=" + version.value) }, - scripted := scripted.dependsOn(core.js / publishLocal, lambda.js / publishLocal).evaluated, + scripted := scripted.dependsOn(lambda.js / publishLocal).evaluated, Test / test := scripted.toTask("").value ) @@ -207,7 +195,6 @@ lazy val unidocs = project inProjects(sbtLambda) else inProjects( - core.jvm, lambda.jvm, lambdaHttp4s.jvm, lambdaCloudFormationCustomResource.jvm diff --git a/core/src/main/scala/feral/IOSetup.scala b/core/src/main/scala/feral/IOSetup.scala deleted file mode 100644 index f8ca3e38..00000000 --- a/core/src/main/scala/feral/IOSetup.scala +++ /dev/null @@ -1,41 +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 - -import cats.effect.IO -import cats.effect.kernel.Resource -import cats.effect.std.Dispatcher -import cats.effect.unsafe.IORuntime -import cats.syntax.all._ - -import scala.concurrent.Future - -private[feral] trait IOSetup { - - protected def runtime: IORuntime = IORuntime.global - - protected type Setup - protected def setup: Resource[IO, Setup] = Resource.pure(null.asInstanceOf[Setup]) - - private[feral] final lazy val setupMemo: Future[(Dispatcher[IO], Setup)] = - (Dispatcher.parallel[IO](await = false), setup) - .tupled - .allocated - .map(_._1) // drop unused finalizer - .unsafeToFuture()(runtime) - -} diff --git a/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala b/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala index d36ff047..a9ff1eab 100644 --- a/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala +++ b/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala @@ -17,12 +17,14 @@ package feral.lambda import cats.effect.IO +import cats.effect.std.Dispatcher +import cats.syntax.all._ import io.circe.scalajs._ import scala.scalajs.js import scala.scalajs.js.JSConverters._ -private[lambda] trait IOLambdaPlatform[Event, Result] { +private[lambda] abstract class IOLambdaPlatform[Event, Result] { this: IOLambda[Event, Result] => final def main(args: Array[String]): Unit = @@ -30,15 +32,24 @@ private[lambda] trait IOLambdaPlatform[Event, Result] { protected def handlerName: String = getClass.getSimpleName.init - private lazy val handlerFn + private[lambda] lazy val handlerFn : js.Function2[js.Any, facade.Context, js.Promise[js.UndefOr[js.Any]]] = { + val dispatcherHandle = { + Dispatcher + .parallel[IO](await = false) + .product(handler) + .allocated + .map(_._1) // drop unused finalizer + .unsafeToPromise()(runtime) + } + (event: js.Any, context: facade.Context) => - setupMemo.toJSPromise(scala.concurrent.ExecutionContext.parasitic).`then`[js.Any] { - case (dispatcher, lambda) => + dispatcherHandle.`then`[js.Any] { + case (dispatcher, handle) => val io = for { event <- IO.fromEither(decodeJs[Event](event)) - result <- lambda(event, Context.fromJS(context)) + result <- handle(Invocation.pure(event, Context.fromJS(context))) } yield result.map(_.asJsAny).orUndefined dispatcher.unsafeToPromise(io) diff --git a/lambda/js/src/main/scala/feral/lambda/facade/Context.scala b/lambda/js/src/main/scala/feral/lambda/facade/Context.scala index 42d2673d..815f24e2 100644 --- a/lambda/js/src/main/scala/feral/lambda/facade/Context.scala +++ b/lambda/js/src/main/scala/feral/lambda/facade/Context.scala @@ -18,48 +18,42 @@ package feral.lambda.facade import scala.scalajs.js -@js.native -private[lambda] sealed trait Context extends js.Object { - def callbackWaitsForEmptyEventLoop: Boolean = js.native - def functionName: String = js.native - def functionVersion: String = js.native - def invokedFunctionArn: String = js.native - def memoryLimitInMB: String = js.native - def awsRequestId: String = js.native - def logGroupName: String = js.native - def logStreamName: String = js.native - def identity: js.UndefOr[CognitoIdentity] = js.native - def clientContext: js.UndefOr[ClientContext] = js.native - def getRemainingTimeInMillis(): Double = js.native +private[lambda] trait Context extends js.Object { + def functionName: String + def functionVersion: String + def invokedFunctionArn: String + def memoryLimitInMB: String + def awsRequestId: String + def logGroupName: String + def logStreamName: String + def identity: js.UndefOr[CognitoIdentity] + def clientContext: js.UndefOr[ClientContext] + def getRemainingTimeInMillis(): Double } -@js.native -private[lambda] sealed trait CognitoIdentity extends js.Object { - def cognitoIdentityId: String = js.native - def cognitoIdentityPoolId: String = js.native +private[lambda] trait CognitoIdentity extends js.Object { + def cognitoIdentityId: String + def cognitoIdentityPoolId: String } -@js.native -private[lambda] sealed trait ClientContext extends js.Object { - def client: ClientContextClient = js.native - def custom: js.UndefOr[js.Any] = js.native - def env: ClientContextEnv = js.native +private[lambda] trait ClientContext extends js.Object { + def client: ClientContextClient + def custom: js.UndefOr[js.Any] + def env: ClientContextEnv } -@js.native -private[lambda] sealed trait ClientContextClient extends js.Object { - def installationId: String = js.native - def appTitle: String = js.native - def appVersionName: String = js.native - def appVersionCode: String = js.native - def appPackageName: String = js.native +private[lambda] trait ClientContextClient extends js.Object { + def installationId: String + def appTitle: String + def appVersionName: String + def appVersionCode: String + def appPackageName: String } -@js.native -private[lambda] sealed trait ClientContextEnv extends js.Object { - def platformVersion: String = js.native - def platform: String = js.native - def make: String = js.native - def model: String = js.native - def locale: String = js.native +private[lambda] trait ClientContextEnv extends js.Object { + def platformVersion: String + def platform: String + def make: String + def model: String + def locale: String } diff --git a/lambda/js/src/test/scala/feral/lambda/IOLambdaJsSuite.scala b/lambda/js/src/test/scala/feral/lambda/IOLambdaJsSuite.scala new file mode 100644 index 00000000..8fa342a1 --- /dev/null +++ b/lambda/js/src/test/scala/feral/lambda/IOLambdaJsSuite.scala @@ -0,0 +1,85 @@ +/* + * 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 + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.syntax.all._ +import io.circe.Json +import io.circe.literal._ +import io.circe.scalajs._ +import munit.CatsEffectSuite + +import java.util.concurrent.atomic.AtomicInteger +import scala.scalajs.js + +class IOLambdaJsSuite extends CatsEffectSuite { + + test("initializes handler once") { + + val allocationCounter = new AtomicInteger + val invokeCounter = new AtomicInteger + val lambda = new IOLambda[String, String] { + def handler = Resource + .eval(IO(allocationCounter.getAndIncrement())) + .as(_.event.map(Some(_)) <* IO(invokeCounter.getAndIncrement())) + } + + val chars = 'A' to 'Z' + chars.toList.traverse { c => + IO.fromPromise(IO(lambda.handlerFn(c.toString, DummyContext))) + .assertEquals(c.toString.asInstanceOf[js.UndefOr[js.Any]]) + } *> IO { + assertEquals(allocationCounter.get(), 1) + assertEquals(invokeCounter.get(), chars.length) + } + } + + test("reads input and writes output") { + + val input = json"""{ "foo": "bar" }""" + val output = json"""{ "woozle": "heffalump" }""" + + val lambda = new IOLambda[Json, Json] { + def handler = Resource.pure(_ => IO(Some(output))) + } + + IO.fromPromise( + IO( + lambda.handlerFn( + input.asJsAny, + DummyContext + ) + ) + ).map(decodeJs[Json](_)) + .assertEquals(Right(output)) + } + + object DummyContext extends facade.Context { + def functionName = "" + def functionVersion = "" + def invokedFunctionArn = "" + def memoryLimitInMB = "0" + def awsRequestId = "" + def logGroupName = "" + def logStreamName = "" + def identity = js.undefined + def clientContext = js.undefined + def getRemainingTimeInMillis(): Double = 0 + } + +} diff --git a/lambda/jvm/src/main/scala/feral/lambda/IOLambdaPlatform.scala b/lambda/jvm/src/main/scala/feral/lambda/IOLambdaPlatform.scala index 419d894d..8c82a6e6 100644 --- a/lambda/jvm/src/main/scala/feral/lambda/IOLambdaPlatform.scala +++ b/lambda/jvm/src/main/scala/feral/lambda/IOLambdaPlatform.scala @@ -17,6 +17,8 @@ package feral.lambda import cats.effect.IO +import cats.effect.std.Dispatcher +import cats.syntax.all._ import com.amazonaws.services.lambda.{runtime => lambdaRuntime} import io.circe.Printer import io.circe.jawn @@ -26,24 +28,29 @@ import java.io.InputStream import java.io.OutputStream import java.io.OutputStreamWriter import java.nio.channels.Channels -import scala.concurrent.Await import scala.concurrent.duration._ private[lambda] abstract class IOLambdaPlatform[Event, Result] extends lambdaRuntime.RequestStreamHandler { this: IOLambda[Event, Result] => + private[this] val (dispatcher, handle) = { + Dispatcher + .parallel[IO](await = false) + .product(handler) + .allocated + .map(_._1) // drop unused finalizer + .unsafeRunSync()(runtime) + } + final def handleRequest( input: InputStream, output: OutputStream, runtimeContext: lambdaRuntime.Context): Unit = { - val (dispatcher, lambda) = - Await.result(setupMemo, runtimeContext.getRemainingTimeInMillis().millis) - val event = jawn.decodeChannel[Event](Channels.newChannel(input)).fold(throw _, identity(_)) val context = Context.fromJava[IO](runtimeContext) dispatcher .unsafeRunTimed( - lambda(event, context), + handle(Invocation.pure(event, context)), runtimeContext.getRemainingTimeInMillis().millis ) .foreach { result => diff --git a/lambda/jvm/src/test/scala/feral/lambda/IOLambdaJvmSuite.scala b/lambda/jvm/src/test/scala/feral/lambda/IOLambdaJvmSuite.scala index ceda72e9..dfc0e438 100644 --- a/lambda/jvm/src/test/scala/feral/lambda/IOLambdaJvmSuite.scala +++ b/lambda/jvm/src/test/scala/feral/lambda/IOLambdaJvmSuite.scala @@ -18,6 +18,7 @@ package feral.lambda import cats.effect.IO import cats.effect.kernel.Resource +import cats.syntax.all._ import com.amazonaws.services.lambda.runtime import io.circe.Json import io.circe.jawn @@ -26,9 +27,38 @@ import munit.FunSuite import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicInteger class IOLambdaJvmSuite extends FunSuite { + test("initializes handler once") { + + val allocationCounter = new AtomicInteger + val invokeCounter = new AtomicInteger + val lambda = new IOLambda[String, String] { + def handler = Resource + .eval(IO(allocationCounter.getAndIncrement())) + .as(_.event.map(Some(_)) <* IO(invokeCounter.getAndIncrement())) + } + + val chars = 'A' to 'Z' + chars.foreach { c => + val os = new ByteArrayOutputStream + + val json = s""""$c"""" + lambda.handleRequest( + new ByteArrayInputStream(json.getBytes()), + os, + DummyContext + ) + + assertEquals(new String(os.toByteArray()), json) + } + + assertEquals(allocationCounter.get(), 1) + assertEquals(invokeCounter.get(), chars.length) + } + test("reads input and writes output") { val input = json"""{ "foo": "bar" }""" diff --git a/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala b/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala index 5f4e15a9..0583604d 100644 --- a/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala +++ b/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala @@ -18,25 +18,17 @@ package feral package lambda import cats.effect.IO -import cats.effect.IOLocal import cats.effect.kernel.Resource +import cats.effect.unsafe.IORuntime import io.circe.Decoder import io.circe.Encoder abstract class IOLambda[Event, Result]( implicit private[lambda] val decoder: Decoder[Event], private[lambda] val encoder: Encoder[Result] -) extends IOLambdaPlatform[Event, Result] - with IOSetup { +) extends IOLambdaPlatform[Event, Result] { - final type Setup = (Event, Context[IO]) => IO[Option[Result]] - final override protected def setup: Resource[IO, Setup] = for { - handler <- handler - localEvent <- IOLocal[Event](null.asInstanceOf[Event]).toResource - localContext <- IOLocal[Context[IO]](null).toResource - inv = Invocation.ioInvocation(localEvent, localContext) - result = handler(inv) - } yield { localEvent.set(_) *> localContext.set(_) *> result } + protected def runtime: IORuntime = IORuntime.global def handler: Resource[IO, Invocation[IO, Event] => IO[Option[Result]]] diff --git a/lambda/shared/src/main/scala/feral/lambda/Invocation.scala b/lambda/shared/src/main/scala/feral/lambda/Invocation.scala index 19744585..114b8335 100644 --- a/lambda/shared/src/main/scala/feral/lambda/Invocation.scala +++ b/lambda/shared/src/main/scala/feral/lambda/Invocation.scala @@ -23,8 +23,6 @@ import cats.data.Kleisli import cats.data.OptionT import cats.data.StateT import cats.data.WriterT -import cats.effect.IO -import cats.effect.IOLocal import cats.kernel.Monoid import cats.syntax.all._ import cats.~> @@ -69,15 +67,6 @@ object Invocation { implicit inv: Invocation[F, A]): Invocation[StateT[F, S, *], A] = inv.mapK(StateT.liftK[F, S]) - private[lambda] def ioInvocation[Event]( - localEvent: IOLocal[Event], - localContext: IOLocal[Context[IO]]): Invocation[IO, Event] = - new Invocation[IO, Event] { - def event = localEvent.get - def context = localContext.get - def mapK[F[_]](fk: IO ~> F) = new MapK(this, fk) - } - private final class MapK[F[_]: Functor, G[_], Event]( underlying: Invocation[F, Event], fk: F ~> G diff --git a/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala b/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala deleted file mode 100644 index fbd41b0c..00000000 --- a/lambda/shared/src/test/scala/feral/lambda/IOLambdaSuite.scala +++ /dev/null @@ -1,51 +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 - -import cats.effect.IO -import cats.effect.kernel.Resource -import cats.syntax.all._ -import munit.CatsEffectSuite - -class IOLambdaSuite extends CatsEffectSuite { - - test("handler is correctly installed") { - for { - allocationCounter <- IO.ref(0) - invokeCounter <- IO.ref(0) - lambda <- IO { - new IOLambda[String, String] { - def handler = Resource - .eval(allocationCounter.getAndUpdate(_ + 1)) - .as(_.event.map(Some(_)) <* invokeCounter.getAndUpdate(_ + 1)) - } - } - - handler <- IO.fromFuture(IO(lambda.setupMemo)).map(_._2) - - _ <- ('0' to 'z') - .map(_.toString) - .toList - .traverse(x => handler(x, mockContext).assertEquals(Some(x))) - _ <- allocationCounter.get.assertEquals(1) - _ <- invokeCounter.get.assertEquals(75) - } yield () - } - - def mockContext = Context[IO]("", "", "", 0, "", "", "", None, None, IO.stub) - -}