Skip to content

Commit

Permalink
Add netlify http4s module
Browse files Browse the repository at this point in the history
  • Loading branch information
kubukoz committed Dec 14, 2021
1 parent 0f3052d commit 02a3609
Show file tree
Hide file tree
Showing 8 changed files with 462 additions and 0 deletions.
15 changes: 15 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ lazy val root =
lambdaHttp4s.jvm,
lambdaCloudFormationCustomResource.js,
lambdaCloudFormationCustomResource.jvm,
netlifyFunctions.js,
examples.js,
examples.jvm
)
Expand Down Expand Up @@ -146,3 +147,17 @@ lazy val examples = crossProject(JSPlatform, JVMPlatform)
.in(file("examples"))
.dependsOn(lambda)
.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
)
)
80 changes: 80 additions & 0 deletions netlify-functions/src/main/scala/feral/netlify/Context.scala
Original file line number Diff line number Diff line change
@@ -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)
)
}
88 changes: 88 additions & 0 deletions netlify-functions/src/main/scala/feral/netlify/IOLambda.scala
Original file line number Diff line number Diff line change
@@ -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]]
}

}
68 changes: 68 additions & 0 deletions netlify-functions/src/main/scala/feral/netlify/LambdaEnv.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
)
)

}
Loading

0 comments on commit 02a3609

Please sign in to comment.