Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Netlify http4s handler #89

Closed
wants to merge 13 commits into from
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ jobs:
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
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 lambda-cloudformation-custom-resource/.jvm/target project/target
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
Expand Down
14 changes: 14 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ lazy val root =
lambdaHttp4s.jvm,
lambdaCloudFormationCustomResource.js,
lambdaCloudFormationCustomResource.jvm,
netlifyFunctions,
examples.js,
examples.jvm
)
Expand Down Expand Up @@ -239,3 +240,16 @@ lazy val examples = crossProject(JSPlatform, JVMPlatform)
.settings(commonSettings)
.dependsOn(lambda, lambdaHttp4s)
.enablePlugins(NoPublishPlugin)

lazy val netlifyFunctions =
project
.in(file("netlify-functions"))
.enablePlugins(ScalaJSPlugin)
.dependsOn(lambda.js)
.settings(
name := "feral-netlify-functions",
libraryDependencies ++= Seq(
"org.http4s" %%% "http4s-server" % http4sVersion,
"io.circe" %%% "circe-scalajs" % circeVersion
)
)
5 changes: 4 additions & 1 deletion lambda/js/src/main/scala/feral/lambda/ContextPlatform.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ private[lambda] trait ContextCompanionPlatform {
context.functionName,
kubukoz marked this conversation as resolved.
Show resolved Hide resolved
context.functionVersion,
context.invokedFunctionArn,
context.memoryLimitInMB.toInt,
kubukoz marked this conversation as resolved.
Show resolved Hide resolved
(context.memoryLimitInMB: Any) match {
case s: String => s.toInt
case i: Int => i
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, why isn't fatal warnings complaining about this 😆

},
context.awsRequestId,
context.logGroupName,
context.logStreamName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: fix this after #116 lands

: js.Function2[js.Any, facade.Context, js.Promise[js.Any | Unit]] = {
(event: js.Any, context: facade.Context) =>
(for {
Expand Down
5 changes: 4 additions & 1 deletion lambda/js/src/main/scala/feral/lambda/facade/Context.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@
package feral.lambda.facade

import scala.scalajs.js
import scala.scalajs.js._
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick :P

Suggested change
import scala.scalajs.js._
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
// Note: This should always be a string, but the Netlify CLI currently passes a number.
// ref: https://github.com/netlify/functions/pull/251
Comment on lines +28 to +29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstreamed again to ashiina/lambda-local#218 but I'm not optimistic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and released!

def memoryLimitInMB: String | Int = js.native
def awsRequestId: String = js.native
def logGroupName: String = js.native
def logStreamName: String = js.native
Expand Down
Original file line number Diff line number Diff line change
@@ -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 cats.data.NonEmptyList
import io.circe.Decoder
import io.circe.Encoder

final case class HttpFunctionEvent(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh also let's add a KernelSource for this so the tracing middleware works too.

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 HttpFunctionEvent {
implicit def decoder: Decoder[HttpFunctionEvent] = Decoder.forProduct10(
"rawUrl",
"rawQuery",
"path",
"httpMethod",
"headers",
"multiValueHeaders",
"queryStringParameters",
"multiValueQueryStringParameters",
"body",
"isBase64Encoded"
)(HttpFunctionEvent.apply)
}

final case class HttpFunctionResult(
statusCode: Int,
headers: Map[String, String],
body: String,
isBase64Encoded: Boolean
)

object HttpFunctionResult {
implicit def encoder: Encoder[HttpFunctionResult] = Encoder.forProduct4(
"statusCode",
"headers",
"body",
"isBase64Encoded"
)(r => (r.statusCode, r.headers, r.body, r.isBase64Encoded))
}
56 changes: 56 additions & 0 deletions netlify-functions/src/main/scala/feral/netlify/IOFunction.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 feral.lambda
import feral.lambda.IOLambda

import scala.scalajs.js

abstract class IOFunction extends IOLambda[HttpFunctionEvent, HttpFunctionResult] {

override def main(args: Array[String]): Unit = {
// Netlify functions require the entrypoint to be called `handler`
js.Dynamic.global.exports.updateDynamic("handler")(handlerFn)
}

}

object IOFunction {

abstract class Simple extends IOFunction {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this extend IOLambda.Simple instead and avoid the repetition?


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: HttpFunctionEvent,
context: lambda.Context[IO],
init: Init): IO[Option[HttpFunctionResult]]
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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
import feral.lambda.LambdaEnv

object NetlifyHttp4sHandler {

def apply[F[_]: Concurrent](
routes: HttpRoutes[F]
)(implicit env: LambdaEnv[F, HttpFunctionEvent]): F[Option[HttpFunctionResult]] =
for {
event <- env.event
method <- Method.fromString(event.httpMethod).liftTo[F]
uri <- Uri.fromString(event.path).liftTo[F]
headers = Headers(event.headers.toList)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this problem also applies to the AWS lambda which is admittedly half-baked, do we need to include multiValueHeaders here as well? Also the rawQuery.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably so, yeah. I'll check and add them

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still not done, todo

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(
HttpFunctionResult(
response.status.code,
response
.headers
.headers
.map {
case Header.Raw(name, value) =>
name.toString -> value
}
.toMap,
responseBody,
isBase64Encoded
)
)

}
27 changes: 27 additions & 0 deletions netlify-functions/src/main/scala/feral/netlify/example.scala
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be removed later

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth moving to examples?

def handle(
event: HttpFunctionEvent,
context: Context[IO],
init: Init): IO[Option[HttpFunctionResult]] = IO.println(s"Hello, world!").as(None)
}