diff --git a/azure-functions/js/src/main/scala/feral/functions/IOAzureHttpFunction.scala b/azure-functions/js/src/main/scala/feral/functions/IOAzureHttpFunction.scala new file mode 100644 index 00000000..df6081e8 --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/IOAzureHttpFunction.scala @@ -0,0 +1,86 @@ +/* + * 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.functions + +import scala.scalajs.js +import scala.scalajs.js.annotation._ + +import feral.functions.facade.InvocationContext +import feral.functions.facade.JSRequest +import feral.functions.util.AppConfig.buildDefaultConfig +import feral.functions.util.AppConfig +import feral.functions.util.Parser + +import org.http4s.HttpApp + +import cats.effect.IO +import cats.effect.Resource +import cats.syntax.all._ +import cats.effect.unsafe.IORuntime +import cats.effect.std.Dispatcher +import feral.functions.facade.Context + +abstract class IOAzureHttpFunction { + protected def handler: Context => Resource[IO, HttpApp[IO]] + protected def appConfig: AppConfig = buildDefaultConfig(handlerFn) + protected def qBound: Int = 100 + + private[functions] val runtime = IORuntime.global + + final def main(args: Array[String]): Unit = + IOAzureHttpFunction.App.http(functionName, appConfig.toJS) + + private[functions] val functionName: String = + getClass.getSimpleName.init + + private[functions] lazy val handlerFn + : js.Function2[JSRequest, InvocationContext, js.Promise[js.UndefOr[js.Any]]] = { + val dispatcherHandle = { + Dispatcher + .parallel[IO](await = true) + .product(Resource.pure(handler)) + .allocated + .map(_._1) // drop unused finalizer, this resource will live for the duration + .unsafeToPromise()(runtime) + } + + (requestJS, context) => { + dispatcherHandle.`then`[js.Any] { + case (dispatcher, handle) => { + val io = for { + request <- Parser.decodeRequest[IO](requestJS) + response <- handle(Context(context)).use(app => app.run(request)) + respEncoded <- Parser.encodeResponse[IO](response, dispatcher, qBound) + } yield respEncoded + + dispatcher.unsafeToPromise(io) + } + } + } + } +} + +object IOAzureHttpFunction { + @js.native + @JSImport("@azure/functions", "app") + object App extends js.Object { + def http( + name: String, + appConfig: js.Object + ): Unit = js.native + } +} diff --git a/azure-functions/js/src/main/scala/feral/functions/facade/Context.scala b/azure-functions/js/src/main/scala/feral/functions/facade/Context.scala new file mode 100644 index 00000000..9a2e1919 --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/facade/Context.scala @@ -0,0 +1,26 @@ +/* + * 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.functions.facade + +final case class Context(context: InvocationContext) { + def log(m: String): Unit = context.log(m) + def trace(m: String): Unit = context.trace(m) + def debug(m: String): Unit = context.debug(m) + def info(m: String): Unit = context.info(m) + def warn(m: String): Unit = context.warn(m) + def error(m: String): Unit = context.error(m) +} diff --git a/azure-functions/js/src/main/scala/feral/functions/facade/InvocationContext.scala b/azure-functions/js/src/main/scala/feral/functions/facade/InvocationContext.scala new file mode 100644 index 00000000..e0b33fbb --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/facade/InvocationContext.scala @@ -0,0 +1,69 @@ +/* + * 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.functions.facade + +import scala.scalajs.js + +@js.native +private[functions] trait InvocationContext extends js.Object { + def log(args: js.Any*): Unit = js.native + def trace(args: js.Any*): Unit = js.native + def debug(args: js.Any*): Unit = js.native + def info(args: js.Any*): Unit = js.native + def warn(args: js.Any*): Unit = js.native + def error(args: js.Any*): Unit = js.native + + def functionId: String = js.native + def functionName: String = js.native + def extraInputs: ExtraInputs = js.native + def extraOutputs: ExtraOutputs = js.native + def retryContext: js.UndefOr[RetryContext] = js.native + def traceContext: js.UndefOr[TraceContext] = js.native + def options: Options = js.native +} + +@js.native +private[functions] trait ExtraInputs extends js.Object { + def get(binding: js.Any): js.Any = js.native +} + +@js.native +private[functions] trait ExtraOutputs extends js.Object { + def set(binding: js.Any, value: js.Any): Unit = js.native +} + +@js.native +private[functions] trait RetryContext extends js.Object { + def retryCount: Int = js.native + def maxRetryCount: Int = js.native + def exception: js.UndefOr[js.Any] = js.native +} + +@js.native +private[functions] trait TraceContext extends js.Object { + def traceParent: js.UndefOr[String] = js.native + def traceState: js.UndefOr[String] = js.native + def attributes: js.UndefOr[js.Dictionary[js.Any]] = js.native +} + +@js.native +private[functions] trait Options extends js.Object { + def trigger: js.UndefOr[js.Any] = js.native + def extraInputs: js.UndefOr[js.Array[js.Any]] = js.native + def extraOutputs: js.UndefOr[js.Array[js.Any]] = js.native + def `return`: js.UndefOr[js.Any] = js.native +} diff --git a/azure-functions/js/src/main/scala/feral/functions/facade/JSRequest.scala b/azure-functions/js/src/main/scala/feral/functions/facade/JSRequest.scala new file mode 100644 index 00000000..01388e86 --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/facade/JSRequest.scala @@ -0,0 +1,127 @@ +/* + * 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.functions.facade + +import scala.scalajs.js +import scala.scalajs.js.typedarray.Uint8Array + +import fs2.Stream +import fs2.Chunk + +import cats.effect.kernel.Async +import cats.syntax.all._ + +@js.native +private[functions] trait JSRequest extends js.Object { + def method: String = js.native + def url: String = js.native + def headers: JSHeaders = js.native + def body: js.UndefOr[JSReadableStream] = js.native +} + +@js.native +private[functions] trait JSHeaders extends js.Object { + def get(name: String): js.UndefOr[String] = js.native + def keys(): js.Iterator[String] = js.native +} + +@js.native +private[functions] trait JSReadableStream extends js.Object { + def getReader(): JSReadableStreamDefaultReader = js.native +} + +@js.native +private[functions] trait JSReadableStreamDefaultReader extends js.Object { + def read(): js.Promise[JSReadObject] = js.native + def releaseLock(): Unit = js.native + def cancel(reason: js.UndefOr[js.Any]): js.Promise[Unit] +} + +@js.native +private[functions] trait JSReadObject extends js.Object { + def value: js.UndefOr[Uint8Array] = js.native + def done: Boolean = js.native +} + +object JSHeaders { + private[functions] def keyList(h: JSHeaders): List[String] = { + val builder = List.newBuilder[String] + val itr = h.keys() + var entity = itr.next() + + while (!entity.done) { + builder.addOne(entity.value) + entity = itr.next() + } + + builder.result() + } + + object Syntax { + // syntax for method like calls??? + } +} + +object JSReadableStream { + private[functions] def toFs2[F[_]: Async]( + streamOption: js.UndefOr[JSReadableStream]): Stream[F, Byte] = { + streamOption.toOption match { + case None => Stream.empty + case Some(null) => Stream.empty + case Some(stream) => { + Stream.eval(Async[F].delay(stream.getReader())).flatMap { reader => + def nextChunk = { + Async[F].fromPromise(Async[F].delay(reader.read())).map { read => + if (read.done) { + None + } else { + val chunk = read + .value + .toOption + .map(arr => Chunk.array[Byte](toByteArray(arr))) + .getOrElse(Chunk.empty[Byte]) + + Some(chunk) + } + } + } + + Stream + .repeatEval(nextChunk) + .unNoneTerminate + .flatMap(Stream.chunk) + .onFinalize(Async[F].delay(reader.releaseLock())) + } + } + } + } + + private def toByteArray(array: Uint8Array): Array[Byte] = { + val builder = Array.newBuilder[Byte] + val length = array.length + var index = 0 + + while (index != length) { + builder.addOne(array(index).toByte) + index = index + 1 + } + + builder.result() + } + + object Syntax {} +} diff --git a/azure-functions/js/src/main/scala/feral/functions/util/AppConfig.scala b/azure-functions/js/src/main/scala/feral/functions/util/AppConfig.scala new file mode 100644 index 00000000..1fcc329b --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/util/AppConfig.scala @@ -0,0 +1,77 @@ +/* + * 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.functions.util + +import AppConfig._ + +import scala.scalajs.js + +import feral.functions.facade.JSRequest +import feral.functions.facade.InvocationContext + +final case class AppConfig( + methods: List[HttpMethod], + authLevel: AuthLevel, + route: AzureRoute, + handlerFn: HandlerFnT) { + private[functions] def toJS: js.Object = { + js.Dynamic + .literal( + methods = methodsToJS(methods), + authLevel = authLevel.value, + route = route.value, + handler = handlerFn + ) + } +} + +object AppConfig { + sealed trait HttpMethod { + private[functions] def value: String = { + this match { + case Get => "GET" + case Post => "POST" + case Put => "PUT" + case Patch => "PATCH" + case Delete => "DELETE" + } + } + } + case object Get extends HttpMethod + case object Post extends HttpMethod + case object Put extends HttpMethod + case object Patch extends HttpMethod + case object Delete extends HttpMethod + + private def methodsToJS(list: List[HttpMethod]): js.Array[String] = { + val dList = list.distinct.map(_.value) + js.Array(dList: _*) + } + + case class AuthLevel(value: String) + val anonymous: AuthLevel = AuthLevel("anonymous") + // add more? + + case class AzureRoute(value: String) + val catchAll: AzureRoute = AzureRoute("{*path}") + // add more? + + type HandlerFnT = js.Function2[JSRequest, InvocationContext, js.Promise[js.UndefOr[js.Any]]] + + private[functions] def buildDefaultConfig(handlerFn: HandlerFnT) = + AppConfig(List(Get, Post, Put, Patch, Delete), anonymous, catchAll, handlerFn) +} diff --git a/azure-functions/js/src/main/scala/feral/functions/util/Parser.scala b/azure-functions/js/src/main/scala/feral/functions/util/Parser.scala new file mode 100644 index 00000000..f5ba1f0b --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/util/Parser.scala @@ -0,0 +1,153 @@ +/* + * 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.functions.util + +import org.http4s.Request +import org.http4s.Method +import org.http4s.Uri +import org.http4s.Header +import org.http4s.Headers +import org.http4s.Response + +import feral.functions.facade.JSRequest +import feral.functions.facade.JSHeaders +import feral.functions.facade.JSReadableStream + +import cats.syntax.all._ + +import cats.effect.kernel.Async +import cats.effect.syntax.all._ +import cats.effect.std.Queue +import cats.effect.kernel.Fiber +import cats.effect.std.Dispatcher + +import org.typelevel.ci.CIString + +import scala.scalajs.js + +import fs2.Stream + +import StreamUtil._ + +object Parser { + private[functions] def decodeRequest[F[_]: Async](request: JSRequest): F[Request[F]] = { + for { + method <- Method.fromString(request.method).liftTo[F] + uri <- Uri.fromString(request.url).liftTo[F] + body = JSReadableStream.toFs2[F](request.body) + headers = { + val builder = List.newBuilder[Header.Raw] + val keys = JSHeaders.keyList(request.headers) + + keys.foreach(k => + builder.addOne(Header.Raw(CIString(k), request.headers.get(k).getOrElse("")))) + + Headers(builder.result()) + } + } yield Request[F]( + method = method, + uri = uri, + headers = headers, + body = body + ) + } + + private[functions] def encodeResponse[F[_]: Async]( + response: Response[F], + dispatcher: Dispatcher[F], + qBound: Int): F[js.Any] = { + for { + q <- Queue.bounded[F, StreamData](qBound) + streamFiber <- createStreamFiber[F](response.body, q) + headers <- createHeaders[F](response.headers) + body <- createBodyStream[F](q, streamFiber, dispatcher) + } yield { + js.Dynamic + .literal( + status = response.status.code, + headers = headers, + body = body + ) + } + } + + private def createStreamFiber[F[_]: Async]( + stream: Stream[F, Byte], + q: Queue[F, StreamData]): F[Fiber[F, Throwable, Unit]] = { + stream + .chunks + .evalMap(chunk => q.offer(Data(chunk))) + .compile + .drain + .attempt + .flatMap { + case Right(_) => q.offer(End) + case Left(e) => q.offer(Error(e)) + } + .start + } + + private def createHeaders[F[_]: Async](responseHeaders: Headers): F[js.Dictionary[String]] = { + val headersList = responseHeaders.headers.map(h => (h.name.toString, h.value)) + js.Dictionary(headersList: _*).pure[F] + } + + private def createBodyStream[F[_]: Async]( + q: Queue[F, StreamData], + streamFiber: Fiber[F, Throwable, Unit], + dispatcher: Dispatcher[F]): F[js.Any] = { + val readableStream: js.Any = js + .Dynamic + .newInstance(js.Dynamic.global.ReadableStream)( + js.Dynamic + .literal( + start = (_: js.Dynamic) => { /*NoOp*/ }, + pull = (controller: js.Dynamic) => { + val effect = createPullEffect[F](controller, q) + dispatcher.unsafeToPromise(effect) + }, + cancel = (_: js.UndefOr[js.Any]) => { + dispatcher.unsafeToPromise(streamFiber.cancel) + } + ) + ) + + readableStream.pure[F] + } + + private def createPullEffect[F[_]: Async]( + controller: js.Dynamic, + q: Queue[F, StreamData]): F[Unit] = { + q.take.flatMap { + case Error(e) => { + Async[F].delay(controller.error(e.getMessage)).void + } + case End => { + Async[F].delay(controller.close()).void + } + case Data(chunk) => { + Async[F].delay { + val array = new js.typedarray.Uint8Array(chunk.size) + chunk.toArray.zipWithIndex.foreach { + case (byte, index) => array(index) = (byte & 0xff).toShort + } + controller.enqueue(array) + }.void + } + } + } +} diff --git a/azure-functions/js/src/main/scala/feral/functions/util/StreamUtil.scala b/azure-functions/js/src/main/scala/feral/functions/util/StreamUtil.scala new file mode 100644 index 00000000..ca56e60e --- /dev/null +++ b/azure-functions/js/src/main/scala/feral/functions/util/StreamUtil.scala @@ -0,0 +1,26 @@ +/* + * 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.functions.util + +import fs2.Chunk + +object StreamUtil { + sealed trait StreamData + final case class Data(chunk: Chunk[Byte]) extends StreamData + case object End extends StreamData + final case class Error(e: Throwable) extends StreamData +} diff --git a/build.sbt b/build.sbt index c22ab4ae..6fb77a50 100644 --- a/build.sbt +++ b/build.sbt @@ -76,6 +76,7 @@ lazy val root = lambdaHttp4s, lambdaCloudFormationCustomResource, googleCloudHttp4s, + azureFunctions, examples, unidocs ) @@ -271,3 +272,26 @@ lazy val googleCloudHttp4s = crossProject(JSPlatform, JVMPlatform) "co.fs2" %%% "fs2-io" % fs2Version ) ) + +lazy val azureFunctions = crossProject(JSPlatform, JVMPlatform) + .in(file("azure-functions")) + .settings( + name := "azure-functions", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % catsEffectVersion, + "io.circe" %%% "circe-scodec" % circeVersion, + "org.http4s" %%% "http4s-server" % http4sVersion, + "co.fs2" %%% "fs2-io" % fs2Version + ), + tlVersionIntroduced := List("2.13", "3").map(_ -> "0.3.1").toMap + ) + .settings(commonSettings) + .jsSettings( + libraryDependencies ++= Seq( + ), + scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } + ) + .jvmSettings( + Test / fork := true, + libraryDependencies ++= Seq() + ) \ No newline at end of file