diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36174d9c..48911dc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,11 +117,11 @@ jobs: - 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 google-cloud-http4s/jvm/target lambda-cloudformation-custom-resource/.jvm/target google-cloud-http4s/js/target project/target + run: mkdir -p lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target google-cloud/js/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 google-cloud/jvm/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 google-cloud-http4s/jvm/target lambda-cloudformation-custom-resource/.jvm/target google-cloud-http4s/js/target project/target + run: tar cf targets.tar lambda-cloudformation-custom-resource/.js/target lambda-http4s/.jvm/target unidocs/target google-cloud/js/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 google-cloud/jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/.mergify.yml b/.mergify.yml index aa44cd0a..7b13b5b8 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -28,6 +28,14 @@ pull_request_rules: add: - examples remove: [] +- name: Label google-cloud PRs + conditions: + - files~=^google-cloud/ + actions: + label: + add: + - google-cloud + remove: [] - name: Label google-cloud-http4s PRs conditions: - files~=^google-cloud-http4s/ diff --git a/build.sbt b/build.sbt index c22ab4ae..55ad8aec 100644 --- a/build.sbt +++ b/build.sbt @@ -76,6 +76,7 @@ lazy val root = lambdaHttp4s, lambdaCloudFormationCustomResource, googleCloudHttp4s, + googleCloud, examples, unidocs ) @@ -271,3 +272,32 @@ lazy val googleCloudHttp4s = crossProject(JSPlatform, JVMPlatform) "co.fs2" %%% "fs2-io" % fs2Version ) ) + +lazy val googleCloud = crossProject(JSPlatform, JVMPlatform) + .in(file("google-cloud")) + .settings( + name := "feral-google-cloud", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % catsEffectVersion, + "org.scodec" %%% "scodec-bits" % "1.2.0", + "io.circe" %%% "circe-core" % circeVersion, + "io.circe" %%% "circe-generic" % circeVersion, + "org.scalameta" %%% "munit-scalacheck" % munitVersion % Test, + "org.typelevel" %%% "munit-cats-effect" % munitCEVersion % Test, + "io.circe" %%% "circe-literal" % circeVersion % Test + ), + tlVersionIntroduced := List("2.13", "3").map(_ -> "0.3.2").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.4" % Provided, + "co.fs2" %%% "fs2-io" % fs2Version + ) + ) diff --git a/google-cloud/shared/src/main/scala/feral/google-cloud/events/MessagePublishedData.scala b/google-cloud/shared/src/main/scala/feral/google-cloud/events/MessagePublishedData.scala new file mode 100644 index 00000000..45e9c120 --- /dev/null +++ b/google-cloud/shared/src/main/scala/feral/google-cloud/events/MessagePublishedData.scala @@ -0,0 +1,92 @@ +/* + * 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 +package events + +import io.circe._ +import scodec.bits.ByteVector + +import java.time.Instant + +sealed abstract class MessagePublishedData { + def message: PubsubMessage + def subscription: String + def deliveryAttempt: Option[Int] +} + +object MessagePublishedData { + + def apply( + message: PubsubMessage, + subscription: String, + deliveryAttempt: Option[Int] = None + ): MessagePublishedData = { + new Impl(message, subscription, deliveryAttempt) + } + + implicit val decoder: Decoder[MessagePublishedData] = + Decoder.forProduct3("message", "subscription", "deliveryAttempt")( + MessagePublishedData.apply + ) + + private final case class Impl( + message: PubsubMessage, + subscription: String, + deliveryAttempt: Option[Int] + ) extends MessagePublishedData { + override def productPrefix = "MessagePublishedData" + } +} + +sealed abstract class PubsubMessage { + def data: ByteVector + def attributes: Map[String, String] + def messageId: String + def publishTime: Instant + def orderingKey: Option[String] +} + +object PubsubMessage { + + def apply( + data: ByteVector, + attributes: Map[String, String], + messageId: String, + publishTime: Instant, + orderingKey: Option[String] + ): PubsubMessage = { + new Impl(data, attributes, messageId, publishTime, orderingKey) + } + + import codecs.{decodeByteVector, decodeInstant} + + implicit val decoder: Decoder[PubsubMessage] = Decoder.forProduct5( + "data", + "attributes", + "messageId", + "publishTime", + "orderingKey" + )(PubsubMessage.apply) + + private final case class Impl( + data: ByteVector, + attributes: Map[String, String], + messageId: String, + publishTime: Instant, + orderingKey: Option[String] + ) extends PubsubMessage { override def productPrefix = "PubsubMessage" } +} diff --git a/google-cloud/shared/src/main/scala/feral/google-cloud/events/StorageObjectData.scala b/google-cloud/shared/src/main/scala/feral/google-cloud/events/StorageObjectData.scala new file mode 100644 index 00000000..d706926b --- /dev/null +++ b/google-cloud/shared/src/main/scala/feral/google-cloud/events/StorageObjectData.scala @@ -0,0 +1,184 @@ +/* + * 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.events + +import io.circe._ +import io.circe.generic.semiauto.deriveDecoder + +import java.time.Instant + +import codecs.decodeDateTimetoInstant + +sealed abstract class StorageObjectData { + def contentEncoding: String + def contentDisposition: String + def cacheControl: String + def contentLanguage: String + def metageneration: Int + def timeDeleted: Option[Instant] + def contentType: String + def size: Int + def timeCreated: Option[Instant] + def crc32c: String + def componentCount: Int + def md5Hash: String + def etag: String + def updated: Option[Instant] + def storageClass: String + def kmsKeyName: String + def timeStorageClassUpdated: Option[Instant] + def temporaryHold: Boolean + def retentionExpirationTime: Option[Instant] + def metadata: Map[String, String] + def eventBasedHold: Boolean + def name: String + def id: String + def bucket: String + def generation: Int + def customerEncryption: CustomerEncryption + def mediaLink: String + def selfLink: String + def kind: String +} + +object StorageObjectData { + + def apply( + contentEncoding: String, + contentDisposition: String, + cacheControl: String, + contentLanguage: String, + metageneration: Int, + timeDeleted: Option[Instant], + contentType: String, + size: Int, + timeCreated: Option[Instant], + crc32c: String, + componentCount: Int, + md5Hash: String, + etag: String, + updated: Option[Instant], + storageClass: String, + kmsKeyName: String, + timeStorageClassUpdated: Option[Instant], + temporaryHold: Boolean, + retentionExpirationTime: Option[Instant], + metadata: Map[String, String], + eventBasedHold: Boolean, + name: String, + id: String, + bucket: String, + generation: Int, + customerEncryption: CustomerEncryption, + mediaLink: String, + selfLink: String, + kind: String + ): StorageObjectData = { + new Impl( + contentEncoding, + contentDisposition, + cacheControl, + contentLanguage, + metageneration, + timeDeleted, + contentType, + size, + timeCreated, + crc32c, + componentCount, + md5Hash, + etag, + updated, + storageClass, + kmsKeyName, + timeStorageClassUpdated, + temporaryHold, + retentionExpirationTime, + metadata, + eventBasedHold, + name, + id, + bucket, + generation, + customerEncryption, + mediaLink, + selfLink, + kind + ) + } + + implicit val decoder: Decoder[StorageObjectData] = deriveDecoder[Impl].map(identity) + + private final case class Impl( + contentEncoding: String, + contentDisposition: String, + cacheControl: String, + contentLanguage: String, + metageneration: Int, + timeDeleted: Option[Instant], + contentType: String, + size: Int, + timeCreated: Option[Instant], + crc32c: String, + componentCount: Int, + md5Hash: String, + etag: String, + updated: Option[Instant], + storageClass: String, + kmsKeyName: String, + timeStorageClassUpdated: Option[Instant], + temporaryHold: Boolean, + retentionExpirationTime: Option[Instant], + metadata: Map[String, String], + eventBasedHold: Boolean, + name: String, + id: String, + bucket: String, + generation: Int, + customerEncryption: CustomerEncryption, + mediaLink: String, + selfLink: String, + kind: String + ) extends StorageObjectData { + override def productPrefix = "StorageObjectData" + } +} + +sealed abstract class CustomerEncryption { + def encryptionAlgorithm: String + def keySha256: String +} + +object CustomerEncryption { + + def apply( + encryptionAlgorithm: String, + keySha256: String + ): CustomerEncryption = { + new Impl(encryptionAlgorithm, keySha256) + } + + implicit val decoder: Decoder[CustomerEncryption] = + Decoder.forProduct2("encryptionAlgorithm", "keySha256")( + CustomerEncryption.apply + ) + + private final case class Impl(encryptionAlgorithm: String, keySha256: String) + extends CustomerEncryption { + override def productPrefix = "CustomerEncryption" + } +} diff --git a/google-cloud/shared/src/main/scala/feral/google-cloud/events/codecs.scala b/google-cloud/shared/src/main/scala/feral/google-cloud/events/codecs.scala new file mode 100644 index 00000000..c550b0da --- /dev/null +++ b/google-cloud/shared/src/main/scala/feral/google-cloud/events/codecs.scala @@ -0,0 +1,55 @@ +/* + * 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.events + +import io.circe.Decoder +import io.circe.DecodingFailure +import scodec.bits.ByteVector + +import java.time.Instant +import java.time.format.DateTimeFormatter +import scala.util.Try + +private object codecs { + implicit def decodeInstantFromMillis: Decoder[Instant] = + Decoder.decodeLong.emapTry { millis => Try(Instant.ofEpochMilli(millis)) } + + implicit def decodeDateTimetoInstant: Decoder[Option[Instant]] = + Decoder.instance[Option[Instant]] { c => + c.as[String] match { + case Right("") => Right(None) + case Right(str) => { + Try { + val parsed_date_time = + java.time.ZonedDateTime.parse(str, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + parsed_date_time.toInstant() + }.toEither.left.map(e => DecodingFailure(e.getMessage(), c.history)).map(Some(_)) + } + case Left(_) => Right(None) + } + + } + + implicit def decodeInstant: Decoder[Instant] = + Decoder.decodeString.emapTry { str => Try(Instant.parse(str)) } + + implicit def decodeByteVector: Decoder[ByteVector] = + Decoder.decodeString.emap { str => + ByteVector.fromBase64(str).toRight(s"Invalid base64 string") + } + +} diff --git a/google-cloud/shared/src/test/scala/feral/google-cloud/events/MessagePublishedDataSuite.scala b/google-cloud/shared/src/test/scala/feral/google-cloud/events/MessagePublishedDataSuite.scala new file mode 100644 index 00000000..22b121f8 --- /dev/null +++ b/google-cloud/shared/src/test/scala/feral/google-cloud/events/MessagePublishedDataSuite.scala @@ -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.googlecloud.events + +import io.circe.literal._ +import munit.FunSuite +import scodec.bits.ByteVector + +import java.time.Instant + +class MessagePublishedDataSuite extends FunSuite { + + test("decoder") { + assertEquals(event.as[MessagePublishedData].toTry.get, result) + } + + def event = json""" + { + "subscription": "projects/my-project/subscriptions/my-subscription", + "message": { + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=", + "messageId": "message-id", + "publishTime":"2021-02-05T04:06:14.109Z" + } + } + """ + + def result = MessagePublishedData( + subscription = "projects/my-project/subscriptions/my-subscription", + message = PubsubMessage( + data = ByteVector.fromBase64("dGVzdCBtZXNzYWdlIDM=").get, + attributes = Map("attr1" -> "attr1-value"), + messageId = "message-id", + publishTime = Instant.parse("2021-02-05T04:06:14.109Z"), + orderingKey = None + ) + ) + +} diff --git a/google-cloud/shared/src/test/scala/feral/google-cloud/events/StorageObjectDataSuite.scala b/google-cloud/shared/src/test/scala/feral/google-cloud/events/StorageObjectDataSuite.scala new file mode 100644 index 00000000..4724581d --- /dev/null +++ b/google-cloud/shared/src/test/scala/feral/google-cloud/events/StorageObjectDataSuite.scala @@ -0,0 +1,103 @@ +/* + * 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.events + +import io.circe.literal._ +import munit.FunSuite + +import java.time.Instant + +class StorageObjectDataSuite extends FunSuite { + + test("decoder") { + assertEquals(event.as[StorageObjectData].toTry.get, result) + } + + def event = json""" + { + "bucket": "some-bucket", + "cacheControl": "", + "componentCount": 0, + "contentDisposition": "", + "contentEncoding": "", + "contentLanguage": "", + "contentType": "text/plain", + "crc32c": "rTVTeQ==", + "customerEncryption": { + "encryptionAlgorithm": "AES256", + "keySha256": "abc123" + }, + "etag": "CNHZkbuF/ugCEAE=", + "eventBasedHold": false, + "generation": 12345, + "id": "some-bucket/folder/Test.cs/1587627537231057", + "kind": "storage#object", + "kmsKeyName": "", + "md5Hash": "kF8MuJ5+CTJxvyhHS1xzRg==", + "mediaLink": "https://www.googleapis.com/download/storage/v1/b/some-bucket/o/folder%2FTest.cs?generation=1587627537231057\u0026alt=media", + "metadata": {}, + "metageneration": 1, + "name": "folder/Test.cs", + "retentionExpirationTime": "", + "selfLink": "https://www.googleapis.com/storage/v1/b/some-bucket/o/folder/Test.cs", + "size": 352, + "storageClass": "MULTI_REGIONAL", + "temporaryHold": false, + "timeCreated": "2020-04-23T07:38:57.230Z", + "timeDeleted": "", + "timeStorageClassUpdated": "2020-04-23T07:38:57.230Z", + "updated": "2020-04-23T07:38:57.230Z" + } + """ + + def result = StorageObjectData( + contentEncoding = "", + contentDisposition = "", + cacheControl = "", + contentLanguage = "", + metageneration = 1, + timeDeleted = None, + contentType = "text/plain", + size = 352, + timeCreated = Some(Instant.parse("2020-04-23T07:38:57.230Z")), + crc32c = "rTVTeQ==", + componentCount = 0, + md5Hash = "kF8MuJ5+CTJxvyhHS1xzRg==", + etag = "CNHZkbuF/ugCEAE=", + updated = Some(Instant.parse("2020-04-23T07:38:57.230Z")), + storageClass = "MULTI_REGIONAL", + kmsKeyName = "", + timeStorageClassUpdated = Some(Instant.parse("2020-04-23T07:38:57.230Z")), + temporaryHold = false, + retentionExpirationTime = None, + metadata = Map.empty, + eventBasedHold = false, + name = "folder/Test.cs", + id = "some-bucket/folder/Test.cs/1587627537231057", + bucket = "some-bucket", + generation = 12345, + customerEncryption = CustomerEncryption( + encryptionAlgorithm = "AES256", + keySha256 = "abc123" + ), + mediaLink = + "https://www.googleapis.com/download/storage/v1/b/some-bucket/o/folder%2FTest.cs?generation=1587627537231057\u0026alt=media", + selfLink = "https://www.googleapis.com/storage/v1/b/some-bucket/o/folder/Test.cs", + kind = "storage#object" + ) + +}