diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92bd8fca..5c1b4daf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,19 +29,13 @@ jobs: matrix: os: [ubuntu-latest] scala: [2.12, 3, 2.13] - java: [corretto@8, corretto@11, corretto@17] + java: [corretto@11, corretto@17] project: [rootJS, rootJVM, rootSbtScalafix] exclude: - - scala: 2.12 - java: corretto@11 - scala: 2.12 java: corretto@17 - - scala: 3 - java: corretto@11 - scala: 3 java: corretto@17 - - project: rootJS - java: corretto@11 - project: rootJS java: corretto@17 - project: rootJS @@ -55,24 +49,15 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Java (corretto@8) - id: setup-java-corretto-8 - if: matrix.java == 'corretto@8' - uses: actions/setup-java@v4 - with: - distribution: corretto - java-version: 8 - cache: sbt - - - name: sbt update - if: matrix.java == 'corretto@8' && steps.setup-java-corretto-8.outputs.cache-hit == 'false' - run: sbt +update - - name: Setup Java (corretto@11) id: setup-java-corretto-11 if: matrix.java == 'corretto@11' @@ -109,11 +94,11 @@ jobs: run: sbt githubWorkflowCheck - name: Check headers and formatting - if: matrix.java == 'corretto@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'corretto@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck - name: Check scalafix lints - if: matrix.java == 'corretto@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'corretto@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' 'scalafixAll --check' - name: scalaJSLink @@ -124,20 +109,20 @@ jobs: run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test - name: Check binary compatibility - if: matrix.java == 'corretto@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'corretto@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues - name: Generate API documentation - if: matrix.java == 'corretto@8' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'corretto@11' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target lambda-http4s/.js/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target + run: mkdir -p lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target lambda-http4s/.js/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target google-cloud-http4s/jvm/target lambda-cloudformation-custom-resource/.jvm/target google-cloud-http4s/js/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target lambda-http4s/.js/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target lambda-cloudformation-custom-resource/.jvm/target project/target + run: tar cf targets.tar lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target lambda-http4s/.js/target lambda/js/target scalafix/rules/target lambda/jvm/target sbt-lambda/target google-cloud-http4s/jvm/target lambda-cloudformation-custom-resource/.jvm/target google-cloud-http4s/js/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') @@ -153,27 +138,18 @@ jobs: strategy: matrix: os: [ubuntu-latest] - java: [corretto@8] + java: [corretto@11] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Java (corretto@8) - id: setup-java-corretto-8 - if: matrix.java == 'corretto@8' - uses: actions/setup-java@v4 - with: - distribution: corretto - java-version: 8 - cache: sbt - - - name: sbt update - if: matrix.java == 'corretto@8' && steps.setup-java-corretto-8.outputs.cache-hit == 'false' - run: sbt +update - - name: Setup Java (corretto@11) id: setup-java-corretto-11 if: matrix.java == 'corretto@11' @@ -276,31 +252,22 @@ jobs: dependency-submission: name: Submit Dependencies - if: github.event_name != 'pull_request' + if: github.event.repository.fork == false && github.event_name != 'pull_request' strategy: matrix: os: [ubuntu-latest] - java: [corretto@8] + java: [corretto@11] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Java (corretto@8) - id: setup-java-corretto-8 - if: matrix.java == 'corretto@8' - uses: actions/setup-java@v4 - with: - distribution: corretto - java-version: 8 - cache: sbt - - - name: sbt update - if: matrix.java == 'corretto@8' && steps.setup-java-corretto-8.outputs.cache-hit == 'false' - run: sbt +update - - name: Setup Java (corretto@11) id: setup-java-corretto-11 if: matrix.java == 'corretto@11' diff --git a/.scalafmt.conf b/.scalafmt.conf index f850f9ca..fd94731d 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.8.1 +version = 3.8.3 runner.dialect = Scala213Source3 fileOverride { diff --git a/build.sbt b/build.sbt index 10fd6abe..2464c8a7 100644 --- a/build.sbt +++ b/build.sbt @@ -27,7 +27,7 @@ ThisBuild / developers := List( tlGitHubDev("djspiewak", "Daniel Spiewak") ) -ThisBuild / githubWorkflowJavaVersions := Seq("8", "11", "17").map(JavaSpec.corretto(_)) +ThisBuild / githubWorkflowJavaVersions := Seq("11", "17").map(JavaSpec.corretto(_)) ThisBuild / githubWorkflowBuildMatrixExclusions ++= List("rootJS", "rootJVM").map(p => MatrixExclude(Map("project" -> p, "scala" -> "2.12"))) ++ @@ -51,13 +51,14 @@ val Scala3 = "3.3.3" ThisBuild / crossScalaVersions := Seq(Scala212, Scala3, Scala213) val catsEffectVersion = "3.5.4" -val circeVersion = "0.14.7" -val fs2Version = "3.10.2" -val http4sVersion = "0.23.27" -val natchezVersion = "0.3.5" +val circeVersion = "0.14.10" +val fs2Version = "3.11.0" +val http4sVersion = "0.23.27-10-fa6e976-SNAPSHOT" +val natchezVersion = "0.3.6" val munitScalaCheckVersion = "1.0.0-M12" val munitCEVersion = "2.0.0" val scalacheckEffectVersion = "1.0.4" +ThisBuild / resolvers += "s01-oss-sonatype-org-snapshots" at "https://s01.oss.sonatype.org/content/repositories/snapshots" lazy val commonSettings = Seq( crossScalaVersions := Seq(Scala3, Scala213) @@ -69,6 +70,7 @@ lazy val root = lambda, lambdaHttp4s, lambdaCloudFormationCustomResource, + googleCloudHttp4s, examples, unidocs ) @@ -91,8 +93,8 @@ lazy val lambda = crossProject(JSPlatform, JVMPlatform) "org.tpolecat" %%% "natchez-core" % natchezVersion, "io.circe" %%% "circe-scodec" % circeVersion, "io.circe" %%% "circe-jawn" % circeVersion, - "com.comcast" %%% "ip4s-core" % "3.5.0", - "org.scodec" %%% "scodec-bits" % "1.2.0", + "com.comcast" %%% "ip4s-core" % "3.6.0", + "org.scodec" %%% "scodec-bits" % "1.2.1", "org.scalameta" %%% "munit-scalacheck" % munitScalaCheckVersion % Test, "org.typelevel" %%% "munit-cats-effect" % munitCEVersion % Test, "io.circe" %%% "circe-literal" % circeVersion % Test @@ -105,7 +107,7 @@ lazy val lambda = crossProject(JSPlatform, JVMPlatform) .jsSettings( libraryDependencies ++= Seq( "io.circe" %%% "circe-scalajs" % circeVersion, - "io.github.cquiroz" %%% "scala-java-time" % "2.5.0" + "io.github.cquiroz" %%% "scala-java-time" % "2.6.0" ) ) .jvmSettings( @@ -155,7 +157,7 @@ lazy val lambdaCloudFormationCustomResource = crossProject(JSPlatform, JVMPlatfo case _ => Nil }), libraryDependencies ++= Seq( - "io.monix" %%% "newtypes-core" % "0.2.3", + "io.monix" %%% "newtypes-core" % "0.3.0", "org.http4s" %%% "http4s-client" % http4sVersion, "org.http4s" %%% "http4s-circe" % http4sVersion, "org.http4s" %%% "http4s-dsl" % http4sVersion % Test, @@ -171,19 +173,28 @@ lazy val lambdaCloudFormationCustomResource = crossProject(JSPlatform, JVMPlatfo .dependsOn(lambda) lazy val examples = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Pure) .in(file("examples")) .settings( libraryDependencies ++= Seq( "org.http4s" %%% "http4s-dsl" % http4sVersion, "org.http4s" %%% "http4s-ember-client" % http4sVersion, "org.tpolecat" %%% "natchez-xray" % natchezVersion, - "org.tpolecat" %%% "natchez-http4s" % "0.5.0", + "org.tpolecat" %%% "natchez-http4s" % "0.6.0", "org.tpolecat" %%% "skunk-core" % "0.6.4" ) ) .settings(commonSettings) - .dependsOn(lambda, lambdaHttp4s) + .dependsOn(lambda, lambdaHttp4s, googleCloudHttp4s) + .jsSettings( + scalaJSUseMainModuleInitializer := true, + Compile / mainClass := Some("feral.examples.http4sGoogleCloudHandler"), + scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } + ) + .jvmSettings( + libraryDependencies ++= Seq( + "com.google.cloud.functions.invoker" % "java-function-invoker" % "1.3.1" + ) + ) .enablePlugins(NoPublishPlugin) lazy val unidocs = project @@ -224,3 +235,30 @@ lazy val scalafix = tlScalafixProject startYear := Some(2023), crossScalaVersions := Seq(Scala212) ) + +lazy val googleCloudHttp4s = crossProject(JSPlatform, JVMPlatform) + .in(file("google-cloud-http4s")) + .settings( + name := "feral-google-cloud-http4s", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % catsEffectVersion, + "org.scodec" %%% "scodec-bits" % "1.2.0", + "org.http4s" %%% "http4s-server" % http4sVersion, + "org.scalameta" %%% "munit-scalacheck" % munitVersion % Test, + "org.typelevel" %%% "munit-cats-effect-3" % munitCEVersion % Test + ), + tlVersionIntroduced := List("2.13", "3").map(_ -> "0.3.1").toMap + ) + .settings(commonSettings) + .jsSettings( + libraryDependencies ++= Seq( + "io.github.cquiroz" %%% "scala-java-time" % "2.5.0" + ) + ) + .jvmSettings( + Test / fork := true, + libraryDependencies ++= Seq( + "com.google.cloud.functions" % "functions-framework-api" % "1.1.0" % Provided, + "co.fs2" %%% "fs2-io" % fs2Version + ) + ) diff --git a/examples/js/src/main/scala/feral/examples/Http4sGoogleCloud.scala b/examples/js/src/main/scala/feral/examples/Http4sGoogleCloud.scala new file mode 100644 index 00000000..72406a2b --- /dev/null +++ b/examples/js/src/main/scala/feral/examples/Http4sGoogleCloud.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.examples + +import cats.effect._ +import feral.googlecloud._ +import org.http4s._ +import org.http4s.dsl.io._ + +object http4sGoogleCloudHandler extends IOGoogleCloudHttp { + def handler = { + val app = HttpRoutes + .of[IO] { + case GET -> Root / "hello" / name => + Ok(s"Hello, $name.") + } + .orNotFound + + Resource.pure(app) + + /*Resource.pure(HttpApp.pure(Response[IO](Status.Ok)))*/ + } + +} diff --git a/examples/jvm/src/main/scala/feral/examples/Http4sGoogleCloud.scala b/examples/jvm/src/main/scala/feral/examples/Http4sGoogleCloud.scala new file mode 100644 index 00000000..ec16c4e3 --- /dev/null +++ b/examples/jvm/src/main/scala/feral/examples/Http4sGoogleCloud.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.examples + +import cats.effect._ +import feral.googlecloud._ +import org.http4s._ +import org.http4s.dsl.io._ + +class http4sGoogleCloudHandler extends IOGoogleCloudHttp { + def handler = { + val app = HttpRoutes + .of[IO] { + case GET -> Root / "hello" / name => + Ok(s"Hello, $name.") + } + .orNotFound + + Resource.pure(app) + + /*Resource.pure(HttpApp.pure(Response[IO](Status.Ok)))*/ + } + +} diff --git a/examples/src/main/scala/feral/examples/Http4sLambda.scala b/examples/shared/src/main/scala/feral/examples/Http4sLambda.scala similarity index 100% rename from examples/src/main/scala/feral/examples/Http4sLambda.scala rename to examples/shared/src/main/scala/feral/examples/Http4sLambda.scala diff --git a/examples/src/main/scala/feral/examples/KinesisLambda.scala b/examples/shared/src/main/scala/feral/examples/KinesisLambda.scala similarity index 100% rename from examples/src/main/scala/feral/examples/KinesisLambda.scala rename to examples/shared/src/main/scala/feral/examples/KinesisLambda.scala diff --git a/google-cloud-events/js/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudEvent.scala b/google-cloud-events/js/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudEvent.scala new file mode 100644 index 00000000..d408df1a --- /dev/null +++ b/google-cloud-events/js/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudEvent.scala @@ -0,0 +1,70 @@ +/* + * 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.googlecloud + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.effect.std.Dispatcher +import cats.effect.unsafe.IORuntime +import cats.syntax.all._ +import org.http4s.HttpApp +import org.http4s.nodejs.IncomingMessage +import org.http4s.nodejs.ServerResponse + +import scala.scalajs.js +import scala.scalajs.js.annotation._ + +object IOGoogleCloudEvent { + @js.native + @JSImport("@google-cloud/functions-framework", "cloudEvent") + def cloudEvent( + functionName: String, + // input type should be cloudEvent from cloudevents + handler: js.Function2[???, Unit]): Unit = js.native +} + +abstract class IOGoogleCloudEvent { + + final def main(args: Array[String]): Unit = + IOGoogleCloudEvent.cloudEvent(functionName, handlerFn) + + protected def functionName: String = getClass.getSimpleName.init + + protected def runtime: IORuntime = IORuntime.global + + def handler: Resource[IO, HttpApp[IO]] + + private[googlecloud] lazy val handlerFn + : js.Function2[???, Unit] = { + val dispatcherHandle = { + Dispatcher + .parallel[IO](await = false) + .product(handler) + .allocated + .map(_._1) // drop unused finalizer + .unsafeToPromise()(runtime) + } + + (event: ???) => + val _ = dispatcherHandle.`then`[Unit] { + case (dispatcher, handle) => + dispatcher.unsafeRunAndForget( + request.toRequest[IO].flatMap(handle(_)).flatMap(response.writeResponse[IO]) + ) + } + } +} diff --git a/google-cloud-http4s/js/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala b/google-cloud-http4s/js/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala new file mode 100644 index 00000000..f15d5001 --- /dev/null +++ b/google-cloud-http4s/js/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.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.googlecloud + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.effect.std.Dispatcher +import cats.effect.unsafe.IORuntime +import cats.syntax.all._ +import org.http4s.HttpApp +import org.http4s.nodejs.IncomingMessage +import org.http4s.nodejs.ServerResponse + +import scala.scalajs.js +import scala.scalajs.js.annotation._ + +object IOGoogleCloudHttp { + @js.native + @JSImport("@google-cloud/functions-framework", "http") + def http( + functionName: String, + handler: js.Function2[IncomingMessage, ServerResponse, Unit]): Unit = js.native +} + +abstract class IOGoogleCloudHttp { + + final def main(args: Array[String]): Unit = + IOGoogleCloudHttp.http(functionName, handlerFn) + + protected def functionName: String = getClass.getSimpleName.init + + protected def runtime: IORuntime = IORuntime.global + + def handler: Resource[IO, HttpApp[IO]] + + private[googlecloud] lazy val handlerFn + : js.Function2[IncomingMessage, ServerResponse, Unit] = { + val dispatcherHandle = { + Dispatcher + .parallel[IO](await = false) + .product(handler) + .allocated + .map(_._1) // drop unused finalizer + .unsafeToPromise()(runtime) + } + + (request: IncomingMessage, response: ServerResponse) => + val _ = dispatcherHandle.`then`[Unit] { + case (dispatcher, handle) => + dispatcher.unsafeRunAndForget( + request.toRequest[IO].flatMap(handle(_)).flatMap(response.writeResponse[IO]) + ) + } + } +} diff --git a/google-cloud-http4s/jvm/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala b/google-cloud-http4s/jvm/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala new file mode 100644 index 00000000..bf7c2d83 --- /dev/null +++ b/google-cloud-http4s/jvm/src/main/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala @@ -0,0 +1,125 @@ +/* + * 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.googlecloud + +import cats.effect.Async +import cats.effect.IO +import cats.effect.Resource +import cats.effect.std.Dispatcher +import cats.effect.syntax.all._ +import cats.effect.unsafe.IORuntime +import cats.syntax.all._ +import com.google.cloud.functions.HttpFunction +import com.google.cloud.functions.HttpRequest +import com.google.cloud.functions.HttpResponse +import org.http4s.Header +import org.http4s.Headers +import org.http4s.HttpApp +import org.http4s.Method +import org.http4s.Request +import org.http4s.Response +import org.http4s.Uri +import org.typelevel.ci.CIString + +import scala.util.control.NonFatal + +object IOGoogleCloudHttp { + def fromHttpRequest(request: HttpRequest): IO[Request[IO]] = for { + method <- Method.fromString(request.getMethod()).liftTo[IO] + uri <- Uri.fromString(request.getUri()).liftTo[IO] + headers <- IO { + val builder = List.newBuilder[Header.Raw] + request.getHeaders().forEach { + case (name, values) => + values.forEach { value => builder += Header.Raw(CIString(name), value) } + } + Headers(builder.result()) + } + body = fs2.io.readInputStream(IO(request.getInputStream()), 4096) + } yield Request( + method, + uri, + headers = headers, + body = body + ) + + def writeResponse(http4sResponse: Response[IO], googleResponse: HttpResponse): IO[Unit] = + for { + _ <- IO { + googleResponse.setStatusCode(http4sResponse.status.code, http4sResponse.status.reason) + } + + _ <- IO { + http4sResponse.headers.foreach { header => + { + googleResponse.appendHeader(header.name.toString, header.value) + } + } + } + + _ <- http4sResponse + .body + .through(fs2.io.writeOutputStream(IO(googleResponse.getOutputStream()))) + .compile + .drain + + } yield () +} + +abstract class IOGoogleCloudHttp extends HttpFunction { + + protected def runtime: IORuntime = IORuntime.global + + def handler: Resource[IO, HttpApp[IO]] + + private[this] val (dispatcher, handle) = { + val handler = { + val h = + try this.handler + catch { case ex if NonFatal(ex) => null } + + if (h ne null) { + h.map(IO.pure(_)) + } else { + val functionName = getClass().getSimpleName() + val msg = + s"""|There was an error initializing `$functionName` during startup. + |Falling back to initialize-during-first-invocation strategy. + |To fix, try replacing any `val`s in `$functionName` with `def`s.""".stripMargin + System.err.println(msg) + + Async[Resource[IO, *]].defer(this.handler).memoize.map(_.allocated.map(_._1)) + } + } + + Dispatcher + .parallel[IO](await = false) + .product(handler) + .allocated + .map(_._1) // drop unused finalizer + .unsafeRunSync()(runtime) + } + + final def service(request: HttpRequest, response: HttpResponse): Unit = { + + dispatcher.unsafeRunSync( + IOGoogleCloudHttp.fromHttpRequest(request).flatMap { req => + handle.flatMap(_(req)).flatMap { res => IOGoogleCloudHttp.writeResponse(res, response) } + } + ) + } +} diff --git a/google-cloud-http4s/jvm/src/test/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala b/google-cloud-http4s/jvm/src/test/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala new file mode 100644 index 00000000..f21e4b49 --- /dev/null +++ b/google-cloud-http4s/jvm/src/test/scala/feral/google-cloud/http4s/IOGoogleCloudHttp.scala @@ -0,0 +1,135 @@ +/* + * 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.googlecloud + +import cats.effect.IO +import com.google.cloud.functions.HttpRequest +import com.google.cloud.functions.HttpResponse +import feral.googlecloud.IOGoogleCloudHttp._ +import munit.CatsEffectSuite +import org.http4s.Headers +import org.http4s.Method +import org.http4s.Response +import org.http4s.Uri +import org.http4s.syntax.all._ +import scodec.bits.ByteVector + +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.{util => ju} + +class TestIOGoogleCloudHttp extends CatsEffectSuite { + + class GoogleRequest( + val method: Method, + val uri: Uri, + val headers: Headers, + val body: ByteVector) + extends HttpRequest { + def getMethod(): String = method.name + def getUri(): String = uri.renderString + def getHeaders(): ju.Map[String, ju.List[String]] = { + val juHeaders = new ju.LinkedHashMap[String, ju.List[String]] + headers.foreach { header => + juHeaders + .computeIfAbsent(header.name.toString, _ => new ju.LinkedList[String]()) + .add(header.value) + () + } + juHeaders + } + def getInputStream(): InputStream = body.toInputStream + def getContentType(): ju.Optional[String] = ??? + def getContentLength(): Long = ??? + def getCharacterEncoding(): ju.Optional[String] = ??? + def getReader(): BufferedReader = ??? + def getPath(): String = ??? + def getQuery(): ju.Optional[String] = ??? + def getQueryParameters(): ju.Map[String, ju.List[String]] = ??? + def getParts(): ju.Map[String, HttpRequest.HttpPart] = ??? + } + + class GoogleResponse extends HttpResponse { + var statusCode: Option[(Int, String)] = None + val headers = new ju.HashMap[String, ju.List[String]] + val body = new ByteArrayOutputStream + def setStatusCode(code: Int): Unit = ??? + def setStatusCode(x: Int, y: String): Unit = statusCode = Some((x, y)) + def appendHeader(header: String, value: String): Unit = { + headers.computeIfAbsent(header, _ => new ju.LinkedList[String]()).add(value) + () + } + def getOutputStream(): OutputStream = body + def getHeaders(): ju.Map[String, ju.List[String]] = headers + def getStatusCode(): (Int, String) = statusCode.get + def getContentType(): ju.Optional[String] = ??? + def getWriter(): BufferedWriter = ??? + def setContentType(contentType: String): Unit = ??? + } + + var http4sResponse = Response[IO]() + + test("decode request") { + for { + request <- fromHttpRequest( + new GoogleRequest( + Method.GET, + uri"/default/nodejs-apig-function-1G3XMPLZXVXYI?", + expectedHeaders, + ByteVector.empty)) + _ <- IO(assertEquals(request.method, Method.GET)) + _ <- IO(assertEquals(request.uri, uri"/default/nodejs-apig-function-1G3XMPLZXVXYI?")) + _ <- IO(assertEquals(request.headers, expectedHeaders)) + _ <- request.body.compile.to(ByteVector).assertEquals(ByteVector.empty) + } yield () + } + + test("encode response") { + for { + gResponse <- IO(new GoogleResponse) + _ <- writeResponse(http4sResponse, gResponse) + _ <- IO(assertEquals(gResponse.getStatusCode(), (200, "OK"))) + _ <- IO(assertEquals(gResponse.getHeaders(), new ju.HashMap[String, ju.List[String]]())) + _ <- IO(assertEquals(ByteVector(gResponse.body.toByteArray()), ByteVector.empty)) + } yield () + } + + def expectedHeaders = Headers( + "content-length" -> "0", + "accept-language" -> "en-US,en;q=0.9", + "sec-fetch-dest" -> "document", + "sec-fetch-user" -> "?1", + "x-amzn-trace-id" -> "Root=1-5e6722a7-cc56xmpl46db7ae02d4da47e", + "host" -> "r3pmxmplak.execute-api.us-east-2.amazonaws.com", + "sec-fetch-mode" -> "navigate", + "accept-encoding" -> "gzip, deflate, br", + "accept" -> + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + "sec-fetch-site" -> "cross-site", + "x-forwarded-port" -> "443", + "x-forwarded-proto" -> "https", + "upgrade-insecure-requests" -> "1", + "user-agent" -> + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36", + "x-forwarded-for" -> "205.255.255.176", + "cookie" -> "s_fid=7AABXMPL1AFD9BBF-0643XMPL09956DE2; regStatus=pre-register" + ) + +} diff --git a/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayV2WebSocketEvent.scala b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayV2WebSocketEvent.scala new file mode 100644 index 00000000..f56ea019 --- /dev/null +++ b/lambda/shared/src/main/scala/feral/lambda/events/ApiGatewayV2WebSocketEvent.scala @@ -0,0 +1,151 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.lambda.events + +import com.comcast.ip4s.Hostname +import io.circe.Decoder + +import java.time.Instant + +import codecs.decodeInstant +import codecs.decodeHostname + +sealed abstract class ApiGatewayV2WebSocketEvent { + def stageVariables: Option[Map[String, String]] + def requestContext: WebSocketRequestContext + def body: Option[String] + def isBase64Encoded: Boolean +} + +object ApiGatewayV2WebSocketEvent { + def apply( + stageVariables: Option[Map[String, String]], + requestContext: WebSocketRequestContext, + body: Option[String], + isBase64Encoded: Boolean + ): ApiGatewayV2WebSocketEvent = + new Impl( + stageVariables, + requestContext, + body, + isBase64Encoded + ) + + implicit val decoder: Decoder[ApiGatewayV2WebSocketEvent] = Decoder.forProduct4( + "stageVariables", + "requestContext", + "body", + "isBase64Encoded" + )(ApiGatewayV2WebSocketEvent.apply) + + private final case class Impl( + stageVariables: Option[Map[String, String]], + requestContext: WebSocketRequestContext, + body: Option[String], + isBase64Encoded: Boolean + ) extends ApiGatewayV2WebSocketEvent { + override def productPrefix = "ApiGatewayV2WebSocketEvent" + } +} + +sealed abstract class WebSocketEventType + +object WebSocketEventType { + case object Connect extends WebSocketEventType + case object Message extends WebSocketEventType + case object Disconnect extends WebSocketEventType + + private[events] implicit val decoder: Decoder[WebSocketEventType] = + Decoder.decodeString.map { + case "CONNECT" => Connect + case "MESSAGE" => Message + case "DISCONNECT" => Disconnect + } +} + +sealed abstract class WebSocketRequestContext { + def stage: String + def requestId: String + def apiId: String + def connectedAt: Instant + def connectionId: String + def domainName: Hostname + def eventType: WebSocketEventType + def extendedRequestId: String + def messageId: Option[String] + def requestTime: Instant + def routeKey: String +} + +object WebSocketRequestContext { + def apply( + stage: String, + requestId: String, + apiId: String, + connectedAt: Instant, + connectionId: String, + domainName: Hostname, + eventType: WebSocketEventType, + extendedRequestId: String, + messageId: Option[String], + requestTime: Instant, + routeKey: String + ): WebSocketRequestContext = + new Impl( + stage, + requestId, + apiId, + connectedAt, + connectionId, + domainName, + eventType, + extendedRequestId, + messageId, + requestTime, + routeKey + ) + + private[events] implicit val decoder: Decoder[WebSocketRequestContext] = Decoder.forProduct11( + "stage", + "requestId", + "apiId", + "connectedAt", + "connectionId", + "domainName", + "eventType", + "extendedRequestId", + "messageId", + "requestTimeEpoch", + "routeKey" + )(WebSocketRequestContext.apply) + + private final case class Impl( + stage: String, + requestId: String, + apiId: String, + connectedAt: Instant, + connectionId: String, + domainName: Hostname, + eventType: WebSocketEventType, + extendedRequestId: String, + messageId: Option[String], + requestTime: Instant, + routeKey: String + ) extends WebSocketRequestContext { + override def productPrefix = "WebSocketRequestContext" + } +} diff --git a/lambda/shared/src/main/scala/feral/lambda/events/codecs.scala b/lambda/shared/src/main/scala/feral/lambda/events/codecs.scala index cee02ea1..44713caf 100644 --- a/lambda/shared/src/main/scala/feral/lambda/events/codecs.scala +++ b/lambda/shared/src/main/scala/feral/lambda/events/codecs.scala @@ -16,6 +16,7 @@ package feral.lambda.events +import com.comcast.ip4s.Hostname import com.comcast.ip4s.IpAddress import io.circe.Decoder import io.circe.KeyDecoder @@ -40,6 +41,9 @@ private object codecs { implicit def decodeIpAddress: Decoder[IpAddress] = Decoder.decodeString.emap(IpAddress.fromString(_).toRight("Cannot parse IP address")) + implicit def decodeHostname: Decoder[Hostname] = + Decoder.decodeString.emap(Hostname.fromString(_).toRight("Cannot parse hostname")) + implicit def decodeKeyCIString: KeyDecoder[CIString] = KeyDecoder.decodeKeyString.map(CIString(_)) diff --git a/lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayV2WebSocketEventSuite.scala b/lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayV2WebSocketEventSuite.scala new file mode 100644 index 00000000..48b9b39f --- /dev/null +++ b/lambda/shared/src/test/scala/feral/lambda/events/ApiGatewayV2WebSocketEventSuite.scala @@ -0,0 +1,140 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.lambda.events + +import io.circe.literal._ +import munit.FunSuite + +class ApiGatewayV2WebSocketEventSuite extends FunSuite { + + import ApiGatewayV2WebSocketEventSuite._ + + test("decode connect") { + connectEvent.as[ApiGatewayV2WebSocketEvent].toTry.get + } + + test("decode disconnect") { + disconnectEvent.as[ApiGatewayV2WebSocketEvent].toTry.get + } + +} + +object ApiGatewayV2WebSocketEventSuite { + + def connectEvent = json""" + { + "headers": { + "Host": "abcd123.execute-api.us-east-1.amazonaws.com", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits", + "Sec-WebSocket-Key": "...", + "Sec-WebSocket-Version": "13", + "X-Amzn-Trace-Id": "...", + "X-Forwarded-For": "192.0.2.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": { + "Host": [ + "abcd123.execute-api.us-east-1.amazonaws.com" + ], + "Sec-WebSocket-Extensions": [ + "permessage-deflate; client_max_window_bits" + ], + "Sec-WebSocket-Key": [ + "..." + ], + "Sec-WebSocket-Version": [ + "13" + ], + "X-Amzn-Trace-Id": [ + "..." + ], + "X-Forwarded-For": [ + "192.0.2.1" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ] + }, + "requestContext": { + "routeKey": "$$connect", + "eventType": "CONNECT", + "extendedRequestId": "ABCD1234=", + "requestTime": "09/Feb/2024:18:11:43 +0000", + "messageDirection": "IN", + "stage": "prod", + "connectedAt": 1707502303419, + "requestTimeEpoch": 1707502303420, + "identity": { + "sourceIp": "192.0.2.1" + }, + "requestId": "ABCD1234=", + "domainName": "abcd1234.execute-api.us-east-1.amazonaws.com", + "connectionId": "AAAA1234=", + "apiId": "abcd1234" + }, + "isBase64Encoded": false +} + """ + def disconnectEvent = json""" + { + "headers": { + "Host": "abcd1234.execute-api.us-east-1.amazonaws.com", + "x-api-key": "", + "X-Forwarded-For": "", + "x-restapi": "" + }, + "multiValueHeaders": { + "Host": [ + "abcd1234.execute-api.us-east-1.amazonaws.com" + ], + "x-api-key": [ + "" + ], + "X-Forwarded-For": [ + "" + ], + "x-restapi": [ + "" + ] + }, + "requestContext": { + "routeKey": "$$disconnect", + "disconnectStatusCode": 1005, + "eventType": "DISCONNECT", + "extendedRequestId": "ABCD1234=", + "requestTime": "09/Feb/2024:18:23:28 +0000", + "messageDirection": "IN", + "disconnectReason": "Client-side close frame status not set", + "stage": "prod", + "connectedAt": 1707503007396, + "requestTimeEpoch": 1707503008941, + "identity": { + "sourceIp": "192.0.2.1" + }, + "requestId": "ABCD1234=", + "domainName": "abcd1234.execute-api.us-east-1.amazonaws.com", + "connectionId": "AAAA1234=", + "apiId": "abcd1234" + }, + "isBase64Encoded": false +} + """ +} diff --git a/project/build.properties b/project/build.properties index 081fdbbc..ee4c672c 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.0 +sbt.version=1.10.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 4b385b9f..03b5fc80 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,4 @@ -val sbtlTlV = "0.7.1" +val sbtlTlV = "0.7.3" addSbtPlugin("org.typelevel" % "sbt-typelevel" % sbtlTlV) addSbtPlugin("org.typelevel" % "sbt-typelevel-scalafix" % sbtlTlV) addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") diff --git a/sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/project/build.properties b/sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/project/build.properties index 081fdbbc..ee4c672c 100644 --- a/sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/project/build.properties +++ b/sbt-lambda/src/sbt-test/lambda-js-plugin/iolambda-simple/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.0 +sbt.version=1.10.1