From 5732684284dbedfee295056ef3b823e7b4e49acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 00:06:57 +0100 Subject: [PATCH 01/13] Add netlify http4s module --- build.sbt | 15 ++++ .../main/scala/feral/netlify/Context.scala | 80 +++++++++++++++++ .../main/scala/feral/netlify/IOLambda.scala | 88 +++++++++++++++++++ .../main/scala/feral/netlify/LambdaEnv.scala | 68 ++++++++++++++ .../feral/netlify/NetlifyHttp4sHandler.scala | 73 +++++++++++++++ .../feral/netlify/NetlifyHttpEvent.scala | 65 ++++++++++++++ .../scala/feral/netlify/facade/Context.scala | 35 ++++++++ .../main/scala/feral/netlify/handler.scala | 38 ++++++++ 8 files changed, 462 insertions(+) create mode 100644 netlify-functions/src/main/scala/feral/netlify/Context.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/IOLambda.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/facade/Context.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/handler.scala diff --git a/build.sbt b/build.sbt index 5bb5acf1..2e945ba0 100644 --- a/build.sbt +++ b/build.sbt @@ -125,6 +125,7 @@ lazy val root = lambdaHttp4s.jvm, lambdaCloudFormationCustomResource.js, lambdaCloudFormationCustomResource.jvm, + netlifyFunctions.js, examples.js, examples.jvm ) @@ -239,3 +240,17 @@ lazy val examples = crossProject(JSPlatform, JVMPlatform) .settings(commonSettings) .dependsOn(lambda, lambdaHttp4s) .enablePlugins(NoPublishPlugin) + +lazy val netlifyFunctions = + crossProject(JSPlatform) + .crossType(CrossType.Pure) + .in(file("netlify-functions")) + .dependsOn(core) + .settings( + scalaJSUseMainModuleInitializer := true, + libraryDependencies ++= Seq( + "org.http4s" %%% "http4s-server" % http4sVersion, + "io.circe" %%% "circe-core" % circeVersion, + "io.circe" %%% "circe-scalajs" % circeVersion + ) + ) diff --git a/netlify-functions/src/main/scala/feral/netlify/Context.scala b/netlify-functions/src/main/scala/feral/netlify/Context.scala new file mode 100644 index 00000000..948f0bbf --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/Context.scala @@ -0,0 +1,80 @@ +/* + * 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.netlify + +import cats.effect.Sync +import cats.~> +import io.circe.Json +import io.circe.scalajs._ + +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.duration._ +import scala.annotation.nowarn + +//todo remodel +final class Context[F[_]] private[netlify] ( + val functionName: String, + val functionVersion: String, + val invokedFunctionArn: String, + val memoryLimitInMB: Int, + val awsRequestId: String, + val logGroupName: String, + val logStreamName: String, + val identity: Option[Map[String, Json]], + val clientContext: Option[Map[String, Json]], + 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)) +} + +object Context { + import scalajs.js.| + + @nowarn("msg=unreachable code") + private def nullableToOption[A](nora: Null | A): Option[A] = nora match { + case null => None + case v => Some(v.asInstanceOf[A]) + } + + private[netlify] def fromJS[F[_]: Sync](context: facade.Context): Context[F] = + new Context( + context.functionName, + context.functionVersion, + context.invokedFunctionArn, + context.memoryLimitInMB, + context.awsRequestId, + context.logGroupName, + context.logStreamName, + context.identity.toOption.flatMap(nullableToOption).map { + decodeJs[Map[String, Json]](_).toOption.get + }, + context.clientContext.toOption.flatMap(nullableToOption).map { + decodeJs[Map[String, Json]](_).toOption.get + }, + Sync[F].delay(context.getRemainingTimeInMillis().millis) + ) +} diff --git a/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala b/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala new file mode 100644 index 00000000..b0659fa2 --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala @@ -0,0 +1,88 @@ +/* + * 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.netlify + +import cats.effect.IO +import cats.effect.IOLocal +import cats.effect.kernel.Resource +import feral.IOSetup +import io.circe.Decoder +import io.circe.Encoder +import io.circe.scalajs._ + +import scala.scalajs.js +import scala.scalajs.js.JSConverters._ +import scala.scalajs.js.| + +abstract class IOLambda[Event, Result]( + implicit private[netlify] val decoder: Decoder[Event], + private[netlify] val encoder: Encoder[Result] +) extends IOSetup { + + final type Setup = (Event, Context[IO]) => IO[Option[Result]] + + final override protected def setup: Resource[IO, Setup] = + handler.map { handler => (event, context) => + for { + event <- IOLocal(event) + context <- IOLocal(context) + env = LambdaEnv.ioLambdaEnv(event, context) + result <- handler(env) + } yield result + } + + def main(args: Array[String]): Unit = { + val handlerName = getClass.getSimpleName.init + js.Dynamic.global.exports.updateDynamic(handlerName)(handlerFn) + } + + private lazy val handlerFn + : js.Function2[js.Any, facade.Context, js.Promise[js.Any | Unit]] = { + (event: js.Any, context: facade.Context) => + (for { + lambda <- setupMemo + event <- IO.fromEither(decodeJs[Event](event)) + result <- lambda(event, Context.fromJS(context)) + } yield result.map(_.asJsAny).orUndefined).unsafeToPromise()(runtime) + } + + def handler: Resource[IO, LambdaEnv[IO, Event] => IO[Option[Result]]] + +} + +object IOLambda { + + abstract class Simple[Event, Result]( + implicit decoder: Decoder[Event], + encoder: Encoder[Result]) + extends IOLambda[Event, Result] { + + type Init + def init: Resource[IO, Init] = Resource.pure(null.asInstanceOf[Init]) + + final def handler = init.map { init => env => + for { + event <- env.event + ctx <- env.context + result <- handle(event, ctx, init) + } yield result + } + + def handle(event: Event, context: Context[IO], init: Init): IO[Option[Result]] + } + +} diff --git a/netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala b/netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala new file mode 100644 index 00000000..9ad47aa1 --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala @@ -0,0 +1,68 @@ +/* + * 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.netlify + +import cats.Applicative +import cats.Functor +import cats.data.EitherT +import cats.data.Kleisli +import cats.data.OptionT +import cats.data.WriterT +import cats.effect.IO +import cats.effect.IOLocal +import cats.kernel.Monoid +import cats.syntax.all._ +import cats.~> + +sealed trait LambdaEnv[F[_], Event] { outer => + def event: F[Event] + def context: F[Context[F]] + + final def mapK[G[_]: Functor](f: F ~> G): LambdaEnv[G, Event] = + new LambdaEnv[G, Event] { + def event = f(outer.event) + def context = f(outer.context).map(_.mapK(f)) + } +} + +object LambdaEnv { + def apply[F[_], A](implicit env: LambdaEnv[F, A]): LambdaEnv[F, A] = env + + implicit def kleisliLambdaEnv[F[_]: Functor, A, B]( + implicit env: LambdaEnv[F, A]): LambdaEnv[Kleisli[F, B, *], A] = + env.mapK(Kleisli.liftK) + + implicit def optionTLambdaEnv[F[_]: Functor, A]( + implicit env: LambdaEnv[F, A]): LambdaEnv[OptionT[F, *], A] = + env.mapK(OptionT.liftK) + + implicit def eitherTLambdaEnv[F[_]: Functor, A, B]( + implicit env: LambdaEnv[F, A]): LambdaEnv[EitherT[F, B, *], A] = + env.mapK(EitherT.liftK) + + implicit def writerTLambdaEnv[F[_]: Applicative, A, B: Monoid]( + implicit env: LambdaEnv[F, A]): LambdaEnv[WriterT[F, B, *], A] = + env.mapK(WriterT.liftK[F, B]) + + private[netlify] def ioLambdaEnv[Event]( + localEvent: IOLocal[Event], + localContext: IOLocal[Context[IO]]): LambdaEnv[IO, Event] = + new LambdaEnv[IO, Event] { + def event = localEvent.get + def context = localContext.get + } +} diff --git a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala new file mode 100644 index 00000000..a03f0dc3 --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala @@ -0,0 +1,73 @@ +/* + * 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.netlify + +import cats.effect.kernel.Concurrent +import cats.syntax.all._ +import fs2.Stream +import org.http4s.Charset +import org.http4s.Header +import org.http4s.Headers +import org.http4s.HttpRoutes +import org.http4s.Method +import org.http4s.Request +import org.http4s.Response +import org.http4s.Uri + +object NetlifyHttp4sHandler { + + def apply[F[_]: Concurrent]( + routes: HttpRoutes[F] + )(implicit env: LambdaEnv[F, NetlifyHttpEvent]): F[Option[NetlifyHttpResult]] = + for { + event <- env.event + method <- Method.fromString(event.httpMethod).liftTo[F] + uri <- Uri.fromString(event.path).liftTo[F] + headers = Headers(event.headers.toList) + readBody = + if (event.isBase64Encoded) + fs2.text.base64.decode[F] + else + fs2.text.utf8.encode[F] + request = Request( + method, + uri, + headers = headers, + body = Stream.fromOption[F](event.body).through(readBody)) + response <- routes(request).getOrElse(Response.notFound[F]) + isBase64Encoded = !response.charset.contains(Charset.`UTF-8`) + responseBody <- (if (isBase64Encoded) + response.body.through(fs2.text.base64.encode) + else + response.body.through(fs2.text.utf8.decode)).compile.foldMonoid + } yield Some( + NetlifyHttpResult( + response.status.code, + response + .headers + .headers + .map { + case Header.Raw(name, value) => + name.toString -> value + } + .toMap, + responseBody, + isBase64Encoded + ) + ) + +} diff --git a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala new file mode 100644 index 00000000..24e57c58 --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala @@ -0,0 +1,65 @@ +/* + * 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.netlify +import io.circe.Decoder +import cats.data.NonEmptyList + +final case class NetlifyHttpEvent( + rawUrl: String, + rawQuery: String, + path: String, + httpMethod: String, + headers: Map[String, String], + multiValueHeaders: Map[String, NonEmptyList[String]], + queryStringParameters: Map[String, String], + multiValueQueryStringParameters: Map[String, NonEmptyList[String]], + body: Option[String], + isBase64Encoded: Boolean +) + +object NetlifyHttpEvent { + implicit def decoder: Decoder[NetlifyHttpEvent] = Decoder.forProduct10( + "rawUrl", + "rawQuery", + "path", + "httpMethod", + "headers", + "multiValueHeaders", + "queryStringParameters", + "multiValueQueryStringParameters", + "body", + "isBase64Encoded" + )(NetlifyHttpEvent.apply) +} + +import io.circe.Encoder + +final case class NetlifyHttpResult( + statusCode: Int, + headers: Map[String, String], + body: String, + isBase64Encoded: Boolean +) + +object NetlifyHttpResult { + implicit def encoder: Encoder[NetlifyHttpResult] = Encoder.forProduct4( + "statusCode", + "headers", + "body", + "isBase64Encoded" + )(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded)) +} diff --git a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala new file mode 100644 index 00000000..6107fedb --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala @@ -0,0 +1,35 @@ +/* + * 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.netlify.facade + +import scala.scalajs.js +import scala.scalajs.js._ + +@js.native +private[netlify] 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: Int = js.native + def awsRequestId: String = js.native + def logGroupName: String = js.native + def logStreamName: String = js.native + def identity: UndefOr[Null | js.Object] = js.native + def clientContext: UndefOr[Null | js.Object] = js.native + def getRemainingTimeInMillis(): Double = js.native +} diff --git a/netlify-functions/src/main/scala/feral/netlify/handler.scala b/netlify-functions/src/main/scala/feral/netlify/handler.scala new file mode 100644 index 00000000..4705845a --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/handler.scala @@ -0,0 +1,38 @@ +/* + * 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.netlify + +import cats.effect.IO +import cats.effect.kernel.Resource +import org.http4s.HttpRoutes +import org.http4s.Response + +object handler extends IOLambda[NetlifyHttpEvent, NetlifyHttpResult] { + def handler: Resource[IO, LambdaEnv[IO, NetlifyHttpEvent] => IO[Option[NetlifyHttpResult]]] = + Resource.pure { implicit env => + NetlifyHttp4sHandler { + HttpRoutes.of[IO] { req => + req.bodyText.compile.string.map { body => + Response[IO]().withEntity(s"""uri: ${req.uri} + |method: ${req.method} + |body: $body + |headers: ${req.headers}""".stripMargin) + } + } + } + } +} From 5c20d67e0233e3e4cca3ab47f807e7c09046fdf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:52:49 +0100 Subject: [PATCH 02/13] Cleanup build, remove example Co-Authored-By: Arman Bilge --- build.sbt | 10 ++--- .../main/scala/feral/netlify/handler.scala | 38 ------------------- 2 files changed, 5 insertions(+), 43 deletions(-) delete mode 100644 netlify-functions/src/main/scala/feral/netlify/handler.scala diff --git a/build.sbt b/build.sbt index 2e945ba0..a2530ddf 100644 --- a/build.sbt +++ b/build.sbt @@ -125,7 +125,7 @@ lazy val root = lambdaHttp4s.jvm, lambdaCloudFormationCustomResource.js, lambdaCloudFormationCustomResource.jvm, - netlifyFunctions.js, + netlifyFunctions, examples.js, examples.jvm ) @@ -242,12 +242,12 @@ lazy val examples = crossProject(JSPlatform, JVMPlatform) .enablePlugins(NoPublishPlugin) lazy val netlifyFunctions = - crossProject(JSPlatform) - .crossType(CrossType.Pure) + project .in(file("netlify-functions")) - .dependsOn(core) + .enablePlugins(ScalaJSPlugin) + .dependsOn(core.js) .settings( - scalaJSUseMainModuleInitializer := true, + name := "feral-netlify-functions", libraryDependencies ++= Seq( "org.http4s" %%% "http4s-server" % http4sVersion, "io.circe" %%% "circe-core" % circeVersion, diff --git a/netlify-functions/src/main/scala/feral/netlify/handler.scala b/netlify-functions/src/main/scala/feral/netlify/handler.scala deleted file mode 100644 index 4705845a..00000000 --- a/netlify-functions/src/main/scala/feral/netlify/handler.scala +++ /dev/null @@ -1,38 +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.netlify - -import cats.effect.IO -import cats.effect.kernel.Resource -import org.http4s.HttpRoutes -import org.http4s.Response - -object handler extends IOLambda[NetlifyHttpEvent, NetlifyHttpResult] { - def handler: Resource[IO, LambdaEnv[IO, NetlifyHttpEvent] => IO[Option[NetlifyHttpResult]]] = - Resource.pure { implicit env => - NetlifyHttp4sHandler { - HttpRoutes.of[IO] { req => - req.bodyText.compile.string.map { body => - Response[IO]().withEntity(s"""uri: ${req.uri} - |method: ${req.method} - |body: $body - |headers: ${req.headers}""".stripMargin) - } - } - } - } -} From d710e979c49df848978af236064a32c316829319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:54:26 +0100 Subject: [PATCH 03/13] Better nulls Co-Authored-By: Arman Bilge --- .../src/main/scala/feral/netlify/Context.scala | 14 ++------------ .../main/scala/feral/netlify/facade/Context.scala | 4 ++-- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/netlify-functions/src/main/scala/feral/netlify/Context.scala b/netlify-functions/src/main/scala/feral/netlify/Context.scala index 948f0bbf..e00242ae 100644 --- a/netlify-functions/src/main/scala/feral/netlify/Context.scala +++ b/netlify-functions/src/main/scala/feral/netlify/Context.scala @@ -23,9 +23,7 @@ import io.circe.scalajs._ import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration._ -import scala.annotation.nowarn -//todo remodel final class Context[F[_]] private[netlify] ( val functionName: String, val functionVersion: String, @@ -52,14 +50,6 @@ final class Context[F[_]] private[netlify] ( } object Context { - import scalajs.js.| - - @nowarn("msg=unreachable code") - private def nullableToOption[A](nora: Null | A): Option[A] = nora match { - case null => None - case v => Some(v.asInstanceOf[A]) - } - private[netlify] def fromJS[F[_]: Sync](context: facade.Context): Context[F] = new Context( context.functionName, @@ -69,10 +59,10 @@ object Context { context.awsRequestId, context.logGroupName, context.logStreamName, - context.identity.toOption.flatMap(nullableToOption).map { + context.identity.toOption.flatMap(Option(_)).map { decodeJs[Map[String, Json]](_).toOption.get }, - context.clientContext.toOption.flatMap(nullableToOption).map { + context.clientContext.toOption.flatMap(Option(_)).map { decodeJs[Map[String, Json]](_).toOption.get }, Sync[F].delay(context.getRemainingTimeInMillis().millis) diff --git a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala index 6107fedb..231c0d58 100644 --- a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala +++ b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala @@ -29,7 +29,7 @@ private[netlify] sealed trait Context extends js.Object { def awsRequestId: String = js.native def logGroupName: String = js.native def logStreamName: String = js.native - def identity: UndefOr[Null | js.Object] = js.native - def clientContext: UndefOr[Null | js.Object] = js.native + def identity: UndefOr[js.Object] = js.native + def clientContext: UndefOr[js.Object] = js.native def getRemainingTimeInMillis(): Double = js.native } From 0a30a4ebf16ef878e5c3752981c438451a43dfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:55:16 +0100 Subject: [PATCH 04/13] LambdaEnv -> netlify.FunctionsEnv --- .../{LambdaEnv.scala => FunctionEnv.scala} | 22 +++++++++---------- .../main/scala/feral/netlify/IOLambda.scala | 4 ++-- .../feral/netlify/NetlifyHttp4sHandler.scala | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) rename netlify-functions/src/main/scala/feral/netlify/{LambdaEnv.scala => FunctionEnv.scala} (70%) diff --git a/netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala b/netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala similarity index 70% rename from netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala rename to netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala index 9ad47aa1..83e9f873 100644 --- a/netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala +++ b/netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala @@ -28,40 +28,40 @@ import cats.kernel.Monoid import cats.syntax.all._ import cats.~> -sealed trait LambdaEnv[F[_], Event] { outer => +sealed trait FunctionEnv[F[_], Event] { outer => def event: F[Event] def context: F[Context[F]] - final def mapK[G[_]: Functor](f: F ~> G): LambdaEnv[G, Event] = - new LambdaEnv[G, Event] { + final def mapK[G[_]: Functor](f: F ~> G): FunctionEnv[G, Event] = + new FunctionEnv[G, Event] { def event = f(outer.event) def context = f(outer.context).map(_.mapK(f)) } } -object LambdaEnv { - def apply[F[_], A](implicit env: LambdaEnv[F, A]): LambdaEnv[F, A] = env +object FunctionEnv { + def apply[F[_], A](implicit env: FunctionEnv[F, A]): FunctionEnv[F, A] = env implicit def kleisliLambdaEnv[F[_]: Functor, A, B]( - implicit env: LambdaEnv[F, A]): LambdaEnv[Kleisli[F, B, *], A] = + implicit env: FunctionEnv[F, A]): FunctionEnv[Kleisli[F, B, *], A] = env.mapK(Kleisli.liftK) implicit def optionTLambdaEnv[F[_]: Functor, A]( - implicit env: LambdaEnv[F, A]): LambdaEnv[OptionT[F, *], A] = + implicit env: FunctionEnv[F, A]): FunctionEnv[OptionT[F, *], A] = env.mapK(OptionT.liftK) implicit def eitherTLambdaEnv[F[_]: Functor, A, B]( - implicit env: LambdaEnv[F, A]): LambdaEnv[EitherT[F, B, *], A] = + implicit env: FunctionEnv[F, A]): FunctionEnv[EitherT[F, B, *], A] = env.mapK(EitherT.liftK) implicit def writerTLambdaEnv[F[_]: Applicative, A, B: Monoid]( - implicit env: LambdaEnv[F, A]): LambdaEnv[WriterT[F, B, *], A] = + implicit env: FunctionEnv[F, A]): FunctionEnv[WriterT[F, B, *], A] = env.mapK(WriterT.liftK[F, B]) private[netlify] def ioLambdaEnv[Event]( localEvent: IOLocal[Event], - localContext: IOLocal[Context[IO]]): LambdaEnv[IO, Event] = - new LambdaEnv[IO, Event] { + localContext: IOLocal[Context[IO]]): FunctionEnv[IO, Event] = + new FunctionEnv[IO, Event] { def event = localEvent.get def context = localContext.get } diff --git a/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala b/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala index b0659fa2..ffc7700e 100644 --- a/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala +++ b/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala @@ -40,7 +40,7 @@ abstract class IOLambda[Event, Result]( for { event <- IOLocal(event) context <- IOLocal(context) - env = LambdaEnv.ioLambdaEnv(event, context) + env = FunctionEnv.ioLambdaEnv(event, context) result <- handler(env) } yield result } @@ -60,7 +60,7 @@ abstract class IOLambda[Event, Result]( } yield result.map(_.asJsAny).orUndefined).unsafeToPromise()(runtime) } - def handler: Resource[IO, LambdaEnv[IO, Event] => IO[Option[Result]]] + def handler: Resource[IO, FunctionEnv[IO, Event] => IO[Option[Result]]] } diff --git a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala index a03f0dc3..63abf282 100644 --- a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala +++ b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala @@ -32,7 +32,7 @@ object NetlifyHttp4sHandler { def apply[F[_]: Concurrent]( routes: HttpRoutes[F] - )(implicit env: LambdaEnv[F, NetlifyHttpEvent]): F[Option[NetlifyHttpResult]] = + )(implicit env: FunctionEnv[F, NetlifyHttpEvent]): F[Option[NetlifyHttpResult]] = for { event <- env.event method <- Method.fromString(event.httpMethod).liftTo[F] From f49eb2a858360e5f20a6392bf9b6e614603c718b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:56:15 +0100 Subject: [PATCH 05/13] More renames for netlify --- ...lifyHttpEvent.scala => HttpFunctionEvent.scala} | 14 +++++++------- .../netlify/{IOLambda.scala => IOFunction.scala} | 6 +++--- .../scala/feral/netlify/NetlifyHttp4sHandler.scala | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) rename netlify-functions/src/main/scala/feral/netlify/{NetlifyHttpEvent.scala => HttpFunctionEvent.scala} (82%) rename netlify-functions/src/main/scala/feral/netlify/{IOLambda.scala => IOFunction.scala} (95%) diff --git a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala b/netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala similarity index 82% rename from netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala rename to netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala index 24e57c58..b2e13476 100644 --- a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttpEvent.scala +++ b/netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala @@ -18,7 +18,7 @@ package feral.netlify import io.circe.Decoder import cats.data.NonEmptyList -final case class NetlifyHttpEvent( +final case class HttpFunctionEvent( rawUrl: String, rawQuery: String, path: String, @@ -31,8 +31,8 @@ final case class NetlifyHttpEvent( isBase64Encoded: Boolean ) -object NetlifyHttpEvent { - implicit def decoder: Decoder[NetlifyHttpEvent] = Decoder.forProduct10( +object HttpFunctionEvent { + implicit def decoder: Decoder[HttpFunctionEvent] = Decoder.forProduct10( "rawUrl", "rawQuery", "path", @@ -43,20 +43,20 @@ object NetlifyHttpEvent { "multiValueQueryStringParameters", "body", "isBase64Encoded" - )(NetlifyHttpEvent.apply) + )(HttpFunctionEvent.apply) } import io.circe.Encoder -final case class NetlifyHttpResult( +final case class HttpFunctionResult( statusCode: Int, headers: Map[String, String], body: String, isBase64Encoded: Boolean ) -object NetlifyHttpResult { - implicit def encoder: Encoder[NetlifyHttpResult] = Encoder.forProduct4( +object HttpFunctionResult { + implicit def encoder: Encoder[HttpFunctionResult] = Encoder.forProduct4( "statusCode", "headers", "body", diff --git a/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala b/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala similarity index 95% rename from netlify-functions/src/main/scala/feral/netlify/IOLambda.scala rename to netlify-functions/src/main/scala/feral/netlify/IOFunction.scala index ffc7700e..7526d0e3 100644 --- a/netlify-functions/src/main/scala/feral/netlify/IOLambda.scala +++ b/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala @@ -28,7 +28,7 @@ import scala.scalajs.js import scala.scalajs.js.JSConverters._ import scala.scalajs.js.| -abstract class IOLambda[Event, Result]( +abstract class IOFunction[Event, Result]( implicit private[netlify] val decoder: Decoder[Event], private[netlify] val encoder: Encoder[Result] ) extends IOSetup { @@ -64,12 +64,12 @@ abstract class IOLambda[Event, Result]( } -object IOLambda { +object IOFunction { abstract class Simple[Event, Result]( implicit decoder: Decoder[Event], encoder: Encoder[Result]) - extends IOLambda[Event, Result] { + extends IOFunction[Event, Result] { type Init def init: Resource[IO, Init] = Resource.pure(null.asInstanceOf[Init]) diff --git a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala index 63abf282..64e7203e 100644 --- a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala +++ b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala @@ -32,7 +32,7 @@ object NetlifyHttp4sHandler { def apply[F[_]: Concurrent]( routes: HttpRoutes[F] - )(implicit env: FunctionEnv[F, NetlifyHttpEvent]): F[Option[NetlifyHttpResult]] = + )(implicit env: FunctionEnv[F, HttpFunctionEvent]): F[Option[HttpFunctionResult]] = for { event <- env.event method <- Method.fromString(event.httpMethod).liftTo[F] @@ -55,7 +55,7 @@ object NetlifyHttp4sHandler { else response.body.through(fs2.text.utf8.decode)).compile.foldMonoid } yield Some( - NetlifyHttpResult( + HttpFunctionResult( response.status.code, response .headers From 5934a13c624698f0d83b2ff30be3e1209ecc2aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:56:25 +0100 Subject: [PATCH 06/13] Cleanup imports --- .../src/main/scala/feral/netlify/HttpFunctionEvent.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala b/netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala index b2e13476..aab272bf 100644 --- a/netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala +++ b/netlify-functions/src/main/scala/feral/netlify/HttpFunctionEvent.scala @@ -15,8 +15,10 @@ */ package feral.netlify -import io.circe.Decoder + import cats.data.NonEmptyList +import io.circe.Decoder +import io.circe.Encoder final case class HttpFunctionEvent( rawUrl: String, @@ -46,8 +48,6 @@ object HttpFunctionEvent { )(HttpFunctionEvent.apply) } -import io.circe.Encoder - final case class HttpFunctionResult( statusCode: Int, headers: Map[String, String], From cb4c1b38c52fc57cdf86fa0550b4eb5c2d7a5d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:57:00 +0100 Subject: [PATCH 07/13] Hardcode handler name --- .../src/main/scala/feral/netlify/IOFunction.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala b/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala index 7526d0e3..715d43c4 100644 --- a/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala +++ b/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala @@ -46,8 +46,8 @@ abstract class IOFunction[Event, Result]( } def main(args: Array[String]): Unit = { - val handlerName = getClass.getSimpleName.init - js.Dynamic.global.exports.updateDynamic(handlerName)(handlerFn) + // Netlify functions require the entrypoint to be called `handler` + js.Dynamic.global.exports.updateDynamic("handler")(handlerFn) } private lazy val handlerFn From bb1d9d2f9e50749e4e9323163d5075d9a396ac06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 15:59:45 +0100 Subject: [PATCH 08/13] Generate workflows --- .github/workflows/ci.yml | 158 --------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e105a8a7..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,158 +0,0 @@ -# This file was automatically generated by sbt-github-actions using the -# githubWorkflowGenerate task. You should add and commit this file to -# your git repository. It goes without saying that you shouldn't edit -# this file by hand! Instead, if you wish to make changes, you should -# change your sbt build configuration to revise the workflow description -# to meet your needs, then regenerate this file. - -name: Continuous Integration - -on: - pull_request: - branches: ['**'] - push: - branches: ['**'] - tags: [v*] - -env: - SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - JABBA_INDEX: 'https://github.com/typelevel/jdk-index/raw/main/index.json' - SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} - PGP_SECRET: ${{ secrets.PGP_SECRET }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - -jobs: - build: - name: Build and Test - strategy: - matrix: - os: [ubuntu-latest] - scala: [2.12.15, 3.1.0, 2.13.7] - java: [corretto@8, corretto@11] - exclude: - - scala: 2.12.15 - java: corretto@11 - - scala: 3.1.0 - java: corretto@11 - runs-on: ${{ matrix.os }} - steps: - - name: Checkout current branch (full) - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Setup Java and Scala - uses: olafurpg/setup-scala@v13 - with: - java-version: ${{ matrix.java }} - - - name: Cache sbt - uses: actions/cache@v2 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - name: Publish local - if: matrix.scala == '2.12.15' - run: sbt ++${{ matrix.scala }} ++2.13.7 publishLocal - - - name: Setup NodeJS v14 LTS - uses: actions/setup-node@v2 - with: - node-version: 14 - - - name: Check that workflows are up to date - run: sbt ++${{ matrix.scala }} githubWorkflowCheck - - - if: matrix.scala != '2.12.15' - run: sbt ++${{ matrix.scala }} ci - - - name: Run sbt scripted tests - if: matrix.scala == '2.12.15' - run: sbt ++${{ matrix.scala }} scripted - - - name: Make target directories - run: mkdir -p target lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target core/.js/target examples/.js/target lambda-http4s/.js/target core/.jvm/target lambda/js/target examples/.jvm/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target - - - name: Compress target directories - run: tar cf targets.tar target lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target core/.js/target examples/.js/target lambda-http4s/.js/target core/.jvm/target lambda/js/target examples/.jvm/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target - - - name: Upload target directories - uses: actions/upload-artifact@v2 - with: - name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} - path: targets.tar - - publish: - name: Publish Artifacts - needs: [build] - if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - strategy: - matrix: - os: [ubuntu-latest] - scala: [2.13.7] - java: [corretto@8] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout current branch (full) - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Setup Java and Scala - uses: olafurpg/setup-scala@v13 - with: - java-version: ${{ matrix.java }} - - - name: Cache sbt - uses: actions/cache@v2 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - name: Download target directories (2.12.15) - uses: actions/download-artifact@v2 - with: - name: target-${{ matrix.os }}-2.12.15-${{ matrix.java }} - - - name: Inflate target directories (2.12.15) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (3.1.0) - uses: actions/download-artifact@v2 - with: - name: target-${{ matrix.os }}-3.1.0-${{ matrix.java }} - - - name: Inflate target directories (3.1.0) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.13.7) - uses: actions/download-artifact@v2 - with: - name: target-${{ matrix.os }}-2.13.7-${{ matrix.java }} - - - name: Inflate target directories (2.13.7) - run: | - tar xf targets.tar - rm targets.tar - - - name: Import signing key - run: echo $PGP_SECRET | base64 -d | gpg --import - - - run: sbt ++${{ matrix.scala }} release From 7edbec7d6aebb58b878efad03f676086d7eadc5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 15 Dec 2021 18:17:13 +0100 Subject: [PATCH 09/13] Update build.sbt Co-authored-by: Arman Bilge --- build.sbt | 1 - 1 file changed, 1 deletion(-) diff --git a/build.sbt b/build.sbt index a2530ddf..15145e9c 100644 --- a/build.sbt +++ b/build.sbt @@ -250,7 +250,6 @@ lazy val netlifyFunctions = name := "feral-netlify-functions", libraryDependencies ++= Seq( "org.http4s" %%% "http4s-server" % http4sVersion, - "io.circe" %%% "circe-core" % circeVersion, "io.circe" %%% "circe-scalajs" % circeVersion ) ) From 6cab5c792bf4eb08a73a7e8febab2bf3e6d5202f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 22 Dec 2021 01:55:19 +0100 Subject: [PATCH 10/13] Support both number and string for memoryLimitInMB --- netlify-functions/src/main/scala/feral/netlify/Context.scala | 5 ++++- .../src/main/scala/feral/netlify/facade/Context.scala | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/netlify-functions/src/main/scala/feral/netlify/Context.scala b/netlify-functions/src/main/scala/feral/netlify/Context.scala index e00242ae..c217ec7e 100644 --- a/netlify-functions/src/main/scala/feral/netlify/Context.scala +++ b/netlify-functions/src/main/scala/feral/netlify/Context.scala @@ -55,7 +55,10 @@ object Context { context.functionName, context.functionVersion, context.invokedFunctionArn, - context.memoryLimitInMB, + (context.memoryLimitInMB: Any) match { + case s: String => s.toInt + case i: Int => i + }, context.awsRequestId, context.logGroupName, context.logStreamName, diff --git a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala index 231c0d58..78d29de3 100644 --- a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala +++ b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala @@ -25,7 +25,7 @@ private[netlify] sealed trait Context extends js.Object { def functionName: String = js.native def functionVersion: String = js.native def invokedFunctionArn: String = js.native - def memoryLimitInMB: Int = js.native + def memoryLimitInMB: Int | String = js.native def awsRequestId: String = js.native def logGroupName: String = js.native def logStreamName: String = js.native From 43de541d1cf8ca32efcf48267bf5a8db84b6b239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 22 Dec 2021 02:13:29 +0100 Subject: [PATCH 11/13] CI --- .github/workflows/ci.yml | 158 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..192c3649 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,158 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Continuous Integration + +on: + pull_request: + branches: ['**'] + push: + branches: ['**'] + tags: [v*] + +env: + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + JABBA_INDEX: 'https://github.com/typelevel/jdk-index/raw/main/index.json' + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + PGP_SECRET: ${{ secrets.PGP_SECRET }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + build: + name: Build and Test + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.12.15, 3.1.0, 2.13.7] + java: [corretto@8, corretto@11] + exclude: + - scala: 2.12.15 + java: corretto@11 + - scala: 3.1.0 + java: corretto@11 + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup Java and Scala + uses: olafurpg/setup-scala@v13 + with: + java-version: ${{ matrix.java }} + + - name: Cache sbt + uses: actions/cache@v2 + with: + path: | + ~/.sbt + ~/.ivy2/cache + ~/.coursier/cache/v1 + ~/.cache/coursier/v1 + ~/AppData/Local/Coursier/Cache/v1 + ~/Library/Caches/Coursier/v1 + key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} + + - name: Publish local + if: matrix.scala == '2.12.15' + run: sbt ++${{ matrix.scala }} ++2.13.7 publishLocal + + - name: Setup NodeJS v14 LTS + uses: actions/setup-node@v2 + with: + node-version: 14 + + - name: Check that workflows are up to date + run: sbt ++${{ matrix.scala }} githubWorkflowCheck + + - if: matrix.scala != '2.12.15' + run: sbt ++${{ matrix.scala }} ci + + - name: Run sbt scripted tests + if: matrix.scala == '2.12.15' + run: sbt ++${{ matrix.scala }} scripted + + - name: Make target directories + run: mkdir -p target lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target core/.js/target examples/.js/target lambda-http4s/.js/target core/.jvm/target lambda/js/target examples/.jvm/target lambda/jvm/target sbt-lambda/target netlify-functions/target lambda-cloudformation-custom-resource/.jvm/target project/target + + - name: Compress target directories + run: tar cf targets.tar target lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target core/.js/target examples/.js/target lambda-http4s/.js/target core/.jvm/target lambda/js/target examples/.jvm/target lambda/jvm/target sbt-lambda/target netlify-functions/target lambda-cloudformation-custom-resource/.jvm/target project/target + + - name: Upload target directories + uses: actions/upload-artifact@v2 + with: + name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} + path: targets.tar + + publish: + name: Publish Artifacts + needs: [build] + if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + strategy: + matrix: + os: [ubuntu-latest] + scala: [2.13.7] + java: [corretto@8] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup Java and Scala + uses: olafurpg/setup-scala@v13 + with: + java-version: ${{ matrix.java }} + + - name: Cache sbt + uses: actions/cache@v2 + with: + path: | + ~/.sbt + ~/.ivy2/cache + ~/.coursier/cache/v1 + ~/.cache/coursier/v1 + ~/AppData/Local/Coursier/Cache/v1 + ~/Library/Caches/Coursier/v1 + key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} + + - name: Download target directories (2.12.15) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-2.12.15-${{ matrix.java }} + + - name: Inflate target directories (2.12.15) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (3.1.0) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-3.1.0-${{ matrix.java }} + + - name: Inflate target directories (3.1.0) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (2.13.7) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-2.13.7-${{ matrix.java }} + + - name: Inflate target directories (2.13.7) + run: | + tar xf targets.tar + rm targets.tar + + - name: Import signing key + run: echo $PGP_SECRET | base64 -d | gpg --import + + - run: sbt ++${{ matrix.scala }} release From 1eabf48a44ac42ba5e28991e8027eb0da401df91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 22 Dec 2021 02:37:01 +0100 Subject: [PATCH 12/13] Explain union type --- .../src/main/scala/feral/netlify/facade/Context.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala index 78d29de3..bcc447ab 100644 --- a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala +++ b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala @@ -25,6 +25,8 @@ private[netlify] sealed trait Context extends js.Object { def functionName: String = js.native def functionVersion: String = js.native def invokedFunctionArn: String = js.native + // Note: This should always be a string, but the Netlify CLI currently passes a number. + // ref: https://github.com/netlify/functions/pull/251 def memoryLimitInMB: Int | String = js.native def awsRequestId: String = js.native def logGroupName: String = js.native From a1c10308e0f8186d495cd9856e5f7f06cf76a961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 22 Dec 2021 02:55:02 +0100 Subject: [PATCH 13/13] Simplify the whole shebang --- build.sbt | 2 +- .../scala/feral/lambda/ContextPlatform.scala | 5 +- .../scala/feral/lambda/IOLambdaPlatform.scala | 2 +- .../scala/feral/lambda/facade/Context.scala | 5 +- .../main/scala/feral/netlify/Context.scala | 73 ------------------- .../scala/feral/netlify/FunctionEnv.scala | 68 ----------------- .../main/scala/feral/netlify/IOFunction.scala | 50 +++---------- .../feral/netlify/NetlifyHttp4sHandler.scala | 3 +- .../main/scala/feral/netlify/example.scala | 27 +++++++ .../scala/feral/netlify/facade/Context.scala | 37 ---------- 10 files changed, 48 insertions(+), 224 deletions(-) delete mode 100644 netlify-functions/src/main/scala/feral/netlify/Context.scala delete mode 100644 netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala create mode 100644 netlify-functions/src/main/scala/feral/netlify/example.scala delete mode 100644 netlify-functions/src/main/scala/feral/netlify/facade/Context.scala diff --git a/build.sbt b/build.sbt index 15145e9c..236d7eaf 100644 --- a/build.sbt +++ b/build.sbt @@ -245,7 +245,7 @@ lazy val netlifyFunctions = project .in(file("netlify-functions")) .enablePlugins(ScalaJSPlugin) - .dependsOn(core.js) + .dependsOn(lambda.js) .settings( name := "feral-netlify-functions", libraryDependencies ++= Seq( diff --git a/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala b/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala index ca898e69..6ccd374d 100644 --- a/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala +++ b/lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala @@ -27,7 +27,10 @@ private[lambda] trait ContextCompanionPlatform { context.functionName, context.functionVersion, context.invokedFunctionArn, - context.memoryLimitInMB.toInt, + (context.memoryLimitInMB: Any) match { + case s: String => s.toInt + case i: Int => i + }, context.awsRequestId, context.logGroupName, context.logStreamName, diff --git a/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala b/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala index a3d82894..ce471046 100644 --- a/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala +++ b/lambda/js/src/main/scala/feral/lambda/IOLambdaPlatform.scala @@ -31,7 +31,7 @@ private[lambda] trait IOLambdaPlatform[Event, Result] { js.Dynamic.global.exports.updateDynamic(handlerName)(handlerFn) } - private lazy val handlerFn + protected lazy val handlerFn : js.Function2[js.Any, facade.Context, js.Promise[js.Any | Unit]] = { (event: js.Any, context: facade.Context) => (for { 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 deecdf60..7a30fd97 100644 --- a/lambda/js/src/main/scala/feral/lambda/facade/Context.scala +++ b/lambda/js/src/main/scala/feral/lambda/facade/Context.scala @@ -17,6 +17,7 @@ package feral.lambda.facade import scala.scalajs.js +import scala.scalajs.js._ @js.native private[lambda] sealed trait Context extends js.Object { @@ -24,7 +25,9 @@ private[lambda] sealed trait Context extends js.Object { def functionName: String = js.native def functionVersion: String = js.native def invokedFunctionArn: String = js.native - def memoryLimitInMB: String = js.native + // Note: This should always be a string, but the Netlify CLI currently passes a number. + // ref: https://github.com/netlify/functions/pull/251 + def memoryLimitInMB: String | Int = js.native def awsRequestId: String = js.native def logGroupName: String = js.native def logStreamName: String = js.native diff --git a/netlify-functions/src/main/scala/feral/netlify/Context.scala b/netlify-functions/src/main/scala/feral/netlify/Context.scala deleted file mode 100644 index c217ec7e..00000000 --- a/netlify-functions/src/main/scala/feral/netlify/Context.scala +++ /dev/null @@ -1,73 +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.netlify - -import cats.effect.Sync -import cats.~> -import io.circe.Json -import io.circe.scalajs._ - -import scala.concurrent.duration.FiniteDuration -import scala.concurrent.duration._ - -final class Context[F[_]] private[netlify] ( - val functionName: String, - val functionVersion: String, - val invokedFunctionArn: String, - val memoryLimitInMB: Int, - val awsRequestId: String, - val logGroupName: String, - val logStreamName: String, - val identity: Option[Map[String, Json]], - val clientContext: Option[Map[String, Json]], - 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)) -} - -object Context { - private[netlify] def fromJS[F[_]: Sync](context: facade.Context): Context[F] = - new Context( - context.functionName, - context.functionVersion, - context.invokedFunctionArn, - (context.memoryLimitInMB: Any) match { - case s: String => s.toInt - case i: Int => i - }, - context.awsRequestId, - context.logGroupName, - context.logStreamName, - context.identity.toOption.flatMap(Option(_)).map { - decodeJs[Map[String, Json]](_).toOption.get - }, - context.clientContext.toOption.flatMap(Option(_)).map { - decodeJs[Map[String, Json]](_).toOption.get - }, - Sync[F].delay(context.getRemainingTimeInMillis().millis) - ) -} diff --git a/netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala b/netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala deleted file mode 100644 index 83e9f873..00000000 --- a/netlify-functions/src/main/scala/feral/netlify/FunctionEnv.scala +++ /dev/null @@ -1,68 +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.netlify - -import cats.Applicative -import cats.Functor -import cats.data.EitherT -import cats.data.Kleisli -import cats.data.OptionT -import cats.data.WriterT -import cats.effect.IO -import cats.effect.IOLocal -import cats.kernel.Monoid -import cats.syntax.all._ -import cats.~> - -sealed trait FunctionEnv[F[_], Event] { outer => - def event: F[Event] - def context: F[Context[F]] - - final def mapK[G[_]: Functor](f: F ~> G): FunctionEnv[G, Event] = - new FunctionEnv[G, Event] { - def event = f(outer.event) - def context = f(outer.context).map(_.mapK(f)) - } -} - -object FunctionEnv { - def apply[F[_], A](implicit env: FunctionEnv[F, A]): FunctionEnv[F, A] = env - - implicit def kleisliLambdaEnv[F[_]: Functor, A, B]( - implicit env: FunctionEnv[F, A]): FunctionEnv[Kleisli[F, B, *], A] = - env.mapK(Kleisli.liftK) - - implicit def optionTLambdaEnv[F[_]: Functor, A]( - implicit env: FunctionEnv[F, A]): FunctionEnv[OptionT[F, *], A] = - env.mapK(OptionT.liftK) - - implicit def eitherTLambdaEnv[F[_]: Functor, A, B]( - implicit env: FunctionEnv[F, A]): FunctionEnv[EitherT[F, B, *], A] = - env.mapK(EitherT.liftK) - - implicit def writerTLambdaEnv[F[_]: Applicative, A, B: Monoid]( - implicit env: FunctionEnv[F, A]): FunctionEnv[WriterT[F, B, *], A] = - env.mapK(WriterT.liftK[F, B]) - - private[netlify] def ioLambdaEnv[Event]( - localEvent: IOLocal[Event], - localContext: IOLocal[Context[IO]]): FunctionEnv[IO, Event] = - new FunctionEnv[IO, Event] { - def event = localEvent.get - def context = localContext.get - } -} diff --git a/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala b/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala index 715d43c4..b089accf 100644 --- a/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala +++ b/netlify-functions/src/main/scala/feral/netlify/IOFunction.scala @@ -17,59 +17,24 @@ package feral.netlify import cats.effect.IO -import cats.effect.IOLocal import cats.effect.kernel.Resource -import feral.IOSetup -import io.circe.Decoder -import io.circe.Encoder -import io.circe.scalajs._ +import feral.lambda +import feral.lambda.IOLambda import scala.scalajs.js -import scala.scalajs.js.JSConverters._ -import scala.scalajs.js.| -abstract class IOFunction[Event, Result]( - implicit private[netlify] val decoder: Decoder[Event], - private[netlify] val encoder: Encoder[Result] -) extends IOSetup { +abstract class IOFunction extends IOLambda[HttpFunctionEvent, HttpFunctionResult] { - final type Setup = (Event, Context[IO]) => IO[Option[Result]] - - final override protected def setup: Resource[IO, Setup] = - handler.map { handler => (event, context) => - for { - event <- IOLocal(event) - context <- IOLocal(context) - env = FunctionEnv.ioLambdaEnv(event, context) - result <- handler(env) - } yield result - } - - def main(args: Array[String]): Unit = { + override def main(args: Array[String]): Unit = { // Netlify functions require the entrypoint to be called `handler` js.Dynamic.global.exports.updateDynamic("handler")(handlerFn) } - private lazy val handlerFn - : js.Function2[js.Any, facade.Context, js.Promise[js.Any | Unit]] = { - (event: js.Any, context: facade.Context) => - (for { - lambda <- setupMemo - event <- IO.fromEither(decodeJs[Event](event)) - result <- lambda(event, Context.fromJS(context)) - } yield result.map(_.asJsAny).orUndefined).unsafeToPromise()(runtime) - } - - def handler: Resource[IO, FunctionEnv[IO, Event] => IO[Option[Result]]] - } object IOFunction { - abstract class Simple[Event, Result]( - implicit decoder: Decoder[Event], - encoder: Encoder[Result]) - extends IOFunction[Event, Result] { + abstract class Simple extends IOFunction { type Init def init: Resource[IO, Init] = Resource.pure(null.asInstanceOf[Init]) @@ -82,7 +47,10 @@ object IOFunction { } yield result } - def handle(event: Event, context: Context[IO], init: Init): IO[Option[Result]] + def handle( + event: HttpFunctionEvent, + context: lambda.Context[IO], + init: Init): IO[Option[HttpFunctionResult]] } } diff --git a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala index 64e7203e..13b4c0f8 100644 --- a/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala +++ b/netlify-functions/src/main/scala/feral/netlify/NetlifyHttp4sHandler.scala @@ -27,12 +27,13 @@ import org.http4s.Method import org.http4s.Request import org.http4s.Response import org.http4s.Uri +import feral.lambda.LambdaEnv object NetlifyHttp4sHandler { def apply[F[_]: Concurrent]( routes: HttpRoutes[F] - )(implicit env: FunctionEnv[F, HttpFunctionEvent]): F[Option[HttpFunctionResult]] = + )(implicit env: LambdaEnv[F, HttpFunctionEvent]): F[Option[HttpFunctionResult]] = for { event <- env.event method <- Method.fromString(event.httpMethod).liftTo[F] diff --git a/netlify-functions/src/main/scala/feral/netlify/example.scala b/netlify-functions/src/main/scala/feral/netlify/example.scala new file mode 100644 index 00000000..e8e52fa1 --- /dev/null +++ b/netlify-functions/src/main/scala/feral/netlify/example.scala @@ -0,0 +1,27 @@ +/* + * 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.netlify + +import cats.effect.IO +import feral.lambda.Context + +object example extends IOFunction.Simple { + def handle( + event: HttpFunctionEvent, + context: Context[IO], + init: Init): IO[Option[HttpFunctionResult]] = IO.println(s"Hello, world!").as(None) +} diff --git a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala b/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala deleted file mode 100644 index bcc447ab..00000000 --- a/netlify-functions/src/main/scala/feral/netlify/facade/Context.scala +++ /dev/null @@ -1,37 +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.netlify.facade - -import scala.scalajs.js -import scala.scalajs.js._ - -@js.native -private[netlify] 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 - // Note: This should always be a string, but the Netlify CLI currently passes a number. - // ref: https://github.com/netlify/functions/pull/251 - def memoryLimitInMB: Int | String = js.native - def awsRequestId: String = js.native - def logGroupName: String = js.native - def logStreamName: String = js.native - def identity: UndefOr[js.Object] = js.native - def clientContext: UndefOr[js.Object] = js.native - def getRemainingTimeInMillis(): Double = js.native -}