diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala b/service/shared/src/main/scala/works/iterative/core/service/Repository.scala deleted file mode 100644 index fa2b41d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala +++ /dev/null @@ -1,52 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.stream.* - -trait GenericLoadService[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def load(id: Key): Op[Option[Value]] - -trait GenericUpdateNotifyService[Str[+_], Key]: - def updates: Str[Key] - -trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: - type Op[A] = Eff[A] - def loadAll(ids: Seq[Key]): Op[Coll[Value]] - -trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: - type Op[A] = Eff[A] - def find(filter: FilterArg): Op[Coll[Value]] - -trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] - extends GenericLoadService[Eff, Key, Value] - with GenericLoadAllService[Eff, Coll, Key, Value] - with GenericFindService[Eff, Coll, Key, Value, FilterArg] - -trait GenericWriteRepository[Eff[_], -Key, -Value]: - type Op[A] = Eff[A] - def save(key: Key, value: Value): Op[Unit] - -trait GenericRepository[Eff[+_], -Key, Value] - extends GenericReadRepository[Eff, List, Key, Value, Unit] - with GenericWriteRepository[Eff, Key, Value] - -type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] -type LoadAllRepository[-Key, +Value] = - GenericLoadAllService[UIO, List, Key, Value] - -trait ReadRepository[-Key, +Value, -FilterArg] - extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: - override def loadAll(ids: Seq[Key]): UIO[List[Value]] = - // Inefficient implementation, meant to be overridden - ZIO.foreach(ids)(load).map(_.flatten.toList) - -trait UpdateNotifyRepository[Key] - extends GenericUpdateNotifyService[UStream, Key] - -trait WriteRepository[-Key, -Value] - extends GenericWriteRepository[UIO, Key, Value] - -trait Repository[-Key, Value, -FilterArg] - extends ReadRepository[Key, Value, FilterArg] - with WriteRepository[Key, Value] diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala b/service/shared/src/main/scala/works/iterative/core/service/Repository.scala deleted file mode 100644 index fa2b41d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala +++ /dev/null @@ -1,52 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.stream.* - -trait GenericLoadService[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def load(id: Key): Op[Option[Value]] - -trait GenericUpdateNotifyService[Str[+_], Key]: - def updates: Str[Key] - -trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: - type Op[A] = Eff[A] - def loadAll(ids: Seq[Key]): Op[Coll[Value]] - -trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: - type Op[A] = Eff[A] - def find(filter: FilterArg): Op[Coll[Value]] - -trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] - extends GenericLoadService[Eff, Key, Value] - with GenericLoadAllService[Eff, Coll, Key, Value] - with GenericFindService[Eff, Coll, Key, Value, FilterArg] - -trait GenericWriteRepository[Eff[_], -Key, -Value]: - type Op[A] = Eff[A] - def save(key: Key, value: Value): Op[Unit] - -trait GenericRepository[Eff[+_], -Key, Value] - extends GenericReadRepository[Eff, List, Key, Value, Unit] - with GenericWriteRepository[Eff, Key, Value] - -type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] -type LoadAllRepository[-Key, +Value] = - GenericLoadAllService[UIO, List, Key, Value] - -trait ReadRepository[-Key, +Value, -FilterArg] - extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: - override def loadAll(ids: Seq[Key]): UIO[List[Value]] = - // Inefficient implementation, meant to be overridden - ZIO.foreach(ids)(load).map(_.flatten.toList) - -trait UpdateNotifyRepository[Key] - extends GenericUpdateNotifyService[UStream, Key] - -trait WriteRepository[-Key, -Value] - extends GenericWriteRepository[UIO, Key, Value] - -trait Repository[-Key, Value, -FilterArg] - extends ReadRepository[Key, Value, FilterArg] - with WriteRepository[Key, Value] diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala deleted file mode 100644 index 66ad66b..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala +++ /dev/null @@ -1,21 +0,0 @@ -package works.iterative.core.service -package impl - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef -import zio.* - -object InMemoryFileStore: - val layer: ULayer[FileStore] = ZLayer.succeed { - new FileStore: - override def store(file: FileRepr): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(file.name, "#")) - override def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(name, "#")) - override def load(url: String): Op[Option[Array[Byte]]] = - ZIO.succeed(None) - } diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala b/service/shared/src/main/scala/works/iterative/core/service/Repository.scala deleted file mode 100644 index fa2b41d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala +++ /dev/null @@ -1,52 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.stream.* - -trait GenericLoadService[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def load(id: Key): Op[Option[Value]] - -trait GenericUpdateNotifyService[Str[+_], Key]: - def updates: Str[Key] - -trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: - type Op[A] = Eff[A] - def loadAll(ids: Seq[Key]): Op[Coll[Value]] - -trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: - type Op[A] = Eff[A] - def find(filter: FilterArg): Op[Coll[Value]] - -trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] - extends GenericLoadService[Eff, Key, Value] - with GenericLoadAllService[Eff, Coll, Key, Value] - with GenericFindService[Eff, Coll, Key, Value, FilterArg] - -trait GenericWriteRepository[Eff[_], -Key, -Value]: - type Op[A] = Eff[A] - def save(key: Key, value: Value): Op[Unit] - -trait GenericRepository[Eff[+_], -Key, Value] - extends GenericReadRepository[Eff, List, Key, Value, Unit] - with GenericWriteRepository[Eff, Key, Value] - -type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] -type LoadAllRepository[-Key, +Value] = - GenericLoadAllService[UIO, List, Key, Value] - -trait ReadRepository[-Key, +Value, -FilterArg] - extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: - override def loadAll(ids: Seq[Key]): UIO[List[Value]] = - // Inefficient implementation, meant to be overridden - ZIO.foreach(ids)(load).map(_.flatten.toList) - -trait UpdateNotifyRepository[Key] - extends GenericUpdateNotifyService[UStream, Key] - -trait WriteRepository[-Key, -Value] - extends GenericWriteRepository[UIO, Key, Value] - -trait Repository[-Key, Value, -FilterArg] - extends ReadRepository[Key, Value, FilterArg] - with WriteRepository[Key, Value] diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala deleted file mode 100644 index 66ad66b..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala +++ /dev/null @@ -1,21 +0,0 @@ -package works.iterative.core.service -package impl - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef -import zio.* - -object InMemoryFileStore: - val layer: ULayer[FileStore] = ZLayer.succeed { - new FileStore: - override def store(file: FileRepr): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(file.name, "#")) - override def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(name, "#")) - override def load(url: String): Op[Option[Array[Byte]]] = - ZIO.succeed(None) - } diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala deleted file mode 100644 index e3b7d45..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.core.service -package impl - -import zio.* - -trait InMemoryRepository[Key, Value, FilterArg]( - data: Ref[Map[Key, Value]], - filter: FilterArg => Value => Boolean -) extends Repository[Key, Value, FilterArg]: - override def save(key: Key, value: Value): UIO[Unit] = - data.update(_ + (key -> value)) - override def load(key: Key): UIO[Option[Value]] = - data.get.map(_.get(key)) - override def loadAll(keys: Seq[Key]): UIO[List[Value]] = - data.get.map(_.view.filterKeys(keys.contains).values.toList) - override def find(filterArg: FilterArg): UIO[List[Value]] = - data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala b/service/shared/src/main/scala/works/iterative/core/service/Repository.scala deleted file mode 100644 index fa2b41d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala +++ /dev/null @@ -1,52 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.stream.* - -trait GenericLoadService[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def load(id: Key): Op[Option[Value]] - -trait GenericUpdateNotifyService[Str[+_], Key]: - def updates: Str[Key] - -trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: - type Op[A] = Eff[A] - def loadAll(ids: Seq[Key]): Op[Coll[Value]] - -trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: - type Op[A] = Eff[A] - def find(filter: FilterArg): Op[Coll[Value]] - -trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] - extends GenericLoadService[Eff, Key, Value] - with GenericLoadAllService[Eff, Coll, Key, Value] - with GenericFindService[Eff, Coll, Key, Value, FilterArg] - -trait GenericWriteRepository[Eff[_], -Key, -Value]: - type Op[A] = Eff[A] - def save(key: Key, value: Value): Op[Unit] - -trait GenericRepository[Eff[+_], -Key, Value] - extends GenericReadRepository[Eff, List, Key, Value, Unit] - with GenericWriteRepository[Eff, Key, Value] - -type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] -type LoadAllRepository[-Key, +Value] = - GenericLoadAllService[UIO, List, Key, Value] - -trait ReadRepository[-Key, +Value, -FilterArg] - extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: - override def loadAll(ids: Seq[Key]): UIO[List[Value]] = - // Inefficient implementation, meant to be overridden - ZIO.foreach(ids)(load).map(_.flatten.toList) - -trait UpdateNotifyRepository[Key] - extends GenericUpdateNotifyService[UStream, Key] - -trait WriteRepository[-Key, -Value] - extends GenericWriteRepository[UIO, Key, Value] - -trait Repository[-Key, Value, -FilterArg] - extends ReadRepository[Key, Value, FilterArg] - with WriteRepository[Key, Value] diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala deleted file mode 100644 index 66ad66b..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala +++ /dev/null @@ -1,21 +0,0 @@ -package works.iterative.core.service -package impl - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef -import zio.* - -object InMemoryFileStore: - val layer: ULayer[FileStore] = ZLayer.succeed { - new FileStore: - override def store(file: FileRepr): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(file.name, "#")) - override def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(name, "#")) - override def load(url: String): Op[Option[Array[Byte]]] = - ZIO.succeed(None) - } diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala deleted file mode 100644 index e3b7d45..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.core.service -package impl - -import zio.* - -trait InMemoryRepository[Key, Value, FilterArg]( - data: Ref[Map[Key, Value]], - filter: FilterArg => Value => Boolean -) extends Repository[Key, Value, FilterArg]: - override def save(key: Key, value: Value): UIO[Unit] = - data.update(_ + (key -> value)) - override def load(key: Key): UIO[Option[Value]] = - data.get.map(_.get(key)) - override def loadAll(keys: Seq[Key]): UIO[List[Value]] = - data.get.map(_.view.filterKeys(keys.contains).values.toList) - override def find(filterArg: FilterArg): UIO[List[Value]] = - data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala deleted file mode 100644 index 358cb9d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.core.service.impl - -import works.iterative.core.service.IdGenerator -import zio.* - -class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: - def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) - -object UUIDGenerator: - def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = - ZLayer.succeed(UUIDGenerator(f)) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala b/service/shared/src/main/scala/works/iterative/core/service/Repository.scala deleted file mode 100644 index fa2b41d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala +++ /dev/null @@ -1,52 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.stream.* - -trait GenericLoadService[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def load(id: Key): Op[Option[Value]] - -trait GenericUpdateNotifyService[Str[+_], Key]: - def updates: Str[Key] - -trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: - type Op[A] = Eff[A] - def loadAll(ids: Seq[Key]): Op[Coll[Value]] - -trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: - type Op[A] = Eff[A] - def find(filter: FilterArg): Op[Coll[Value]] - -trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] - extends GenericLoadService[Eff, Key, Value] - with GenericLoadAllService[Eff, Coll, Key, Value] - with GenericFindService[Eff, Coll, Key, Value, FilterArg] - -trait GenericWriteRepository[Eff[_], -Key, -Value]: - type Op[A] = Eff[A] - def save(key: Key, value: Value): Op[Unit] - -trait GenericRepository[Eff[+_], -Key, Value] - extends GenericReadRepository[Eff, List, Key, Value, Unit] - with GenericWriteRepository[Eff, Key, Value] - -type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] -type LoadAllRepository[-Key, +Value] = - GenericLoadAllService[UIO, List, Key, Value] - -trait ReadRepository[-Key, +Value, -FilterArg] - extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: - override def loadAll(ids: Seq[Key]): UIO[List[Value]] = - // Inefficient implementation, meant to be overridden - ZIO.foreach(ids)(load).map(_.flatten.toList) - -trait UpdateNotifyRepository[Key] - extends GenericUpdateNotifyService[UStream, Key] - -trait WriteRepository[-Key, -Value] - extends GenericWriteRepository[UIO, Key, Value] - -trait Repository[-Key, Value, -FilterArg] - extends ReadRepository[Key, Value, FilterArg] - with WriteRepository[Key, Value] diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala deleted file mode 100644 index 66ad66b..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala +++ /dev/null @@ -1,21 +0,0 @@ -package works.iterative.core.service -package impl - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef -import zio.* - -object InMemoryFileStore: - val layer: ULayer[FileStore] = ZLayer.succeed { - new FileStore: - override def store(file: FileRepr): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(file.name, "#")) - override def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(name, "#")) - override def load(url: String): Op[Option[Array[Byte]]] = - ZIO.succeed(None) - } diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala deleted file mode 100644 index e3b7d45..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.core.service -package impl - -import zio.* - -trait InMemoryRepository[Key, Value, FilterArg]( - data: Ref[Map[Key, Value]], - filter: FilterArg => Value => Boolean -) extends Repository[Key, Value, FilterArg]: - override def save(key: Key, value: Value): UIO[Unit] = - data.update(_ + (key -> value)) - override def load(key: Key): UIO[Option[Value]] = - data.get.map(_.get(key)) - override def loadAll(keys: Seq[Key]): UIO[List[Value]] = - data.get.map(_.view.filterKeys(keys.contains).values.toList) - override def find(filterArg: FilterArg): UIO[List[Value]] = - data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala deleted file mode 100644 index 358cb9d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.core.service.impl - -import works.iterative.core.service.IdGenerator -import zio.* - -class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: - def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) - -object UUIDGenerator: - def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = - ZLayer.succeed(UUIDGenerator(f)) diff --git a/service/specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service/specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala deleted file mode 100644 index 0548a3f..0000000 --- a/service/specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative.core -package service -package specs - -import zio.* -import zio.test.* - -abstract class KeyValueStoreSpec extends ZIOSpecDefault: - val defaultSpec = suite("Consul spec")( - test("get returns None if key does not exist") { - for - kv <- ZIO.service[StringKeyValueStore] - uuid <- Random.nextUUID - key = s"test/$uuid" - value <- kv.get(key) - yield assertTrue(value.isEmpty) - }, - test("read/put/delete works") { - for - kv <- ZIO.service[StringKeyValueStore] - uuid <- Random.nextUUID - key = s"test/$uuid" - originalValue <- kv.get(key) - _ <- kv.put(key, "test_value") - updatedValue <- kv.get(key) - _ <- kv.remove(key) - removedValue <- kv.get(key) - yield assertTrue( - originalValue.isEmpty, - updatedValue.contains("test_value"), - removedValue.isEmpty - ) - } - ) diff --git a/build.sbt b/build.sbt index 7cc629d..76ad9f9 100644 --- a/build.sbt +++ b/build.sbt @@ -23,26 +23,19 @@ ) ) -lazy val service = crossProject(JSPlatform, JVMPlatform) - .crossType(CrossType.Full) - .settings(name := "iw-support-service") - .in(file("service")) - .settings(IWDeps.useZIO()) - .dependsOn(core) - lazy val entity = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-entity") .in(file("entity")) .settings(IWDeps.useZIO()) - .dependsOn(core, service) + .dependsOn(core) lazy val `service-specs` = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Full) .settings(name := "iw-support-service-specs") - .in(file("service/specs")) + .in(file("service-specs")) .settings(IWDeps.useZIO(), IWDeps.useZIOTest(Compile)) - .dependsOn(service) + .dependsOn(core) lazy val `tapir-support` = crossProject(JSPlatform, JVMPlatform) .in(file("tapir")) @@ -75,7 +68,7 @@ .settings( IWDeps.useZIO() ) - .dependsOn(service, `service-specs`, `tapir-support`) + .dependsOn(core, `service-specs`, `tapir-support`) lazy val codecs = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -203,8 +196,6 @@ core.jvm, entity.js, entity.jvm, - service.js, - service.jvm, `service-specs`.jvm, hashicorp.jvm, codecs.js, diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 5f4c4b1..932d04d 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -7,6 +7,8 @@ import works.iterative.tapir.CustomTapir import works.iterative.core.auth.* import works.iterative.event.EventRecord +import sttp.tapir.CodecFormat +import works.iterative.core.auth.service.AuthenticationError private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -64,6 +66,11 @@ ) given JsonCodec[UserHandle] = DeriveJsonCodec.gen[UserHandle] given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] + given JsonCodec[AccessToken] = + JsonCodec.string.transform(AccessToken(_), _.token) + given JsonCodec[AuthedUserInfo] = DeriveJsonCodec.gen[AuthedUserInfo] + given JsonCodec[AuthenticationError] = + DeriveJsonCodec.gen[AuthenticationError] trait TapirCodecs extends CustomTapir: given fromValidatedStringSchema[A](using @@ -87,5 +94,11 @@ Schema.schemaForInstant.map(i => Some(Moment(i)))(_.toInstant) given Schema[UserHandle] = Schema.derived[UserHandle] given Schema[EventRecord] = Schema.derived[EventRecord] + given Schema[AccessToken] = Schema.string + given Schema[AuthedUserInfo] = Schema.derived[AuthedUserInfo] + given Schema[AuthenticationError] = Schema.derived[AuthenticationError] + + given Codec[String, AccessToken, CodecFormat.TextPlain] = + Codec.string.map(AccessToken(_))(_.token) object Codecs extends Codecs diff --git a/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala new file mode 100644 index 0000000..16211a8 --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service.impl + +import org.scalajs.dom.Storage +import works.iterative.core.service.Repository +import zio.* +import zio.json.* + +// TODO: improve error reporting on generic repositories +// This is good just for prototypes +// And it cannot be used to query data, as there is no way to iterate the storage +class JsStorageRepository[Value: JsonCodec]( + storage: Storage +) extends Repository[String, Value, String]: + + override def load(id: String): UIO[Option[Value]] = { + for + raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) + data <- ZIO.foreach(raw) { r => + ZIO + .fromEither(r.fromJson[Value]) + .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) + } + yield data + }.orDie + + override def save(key: String, value: Value): UIO[Unit] = + ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie + + override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala new file mode 100644 index 0000000..8c88818 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core +package service +package impl + +import zio.* +import zio.stream.* + +class JcaDigestService extends DigestService: + + override def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] = + for + md <- ZIO + .attempt( + java.security.MessageDigest.getInstance(algorithm.value) + ) + .orDie + _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) + digest <- ZIO.attempt(md.digest()).orDie + yield Digest(algorithm, digest) + +object JcaDigestGenerator: + val layer: ULayer[DigestService] = + ZLayer.succeed(new JcaDigestService) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala new file mode 100644 index 0000000..d489e6b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/AuthedUserInfo.scala @@ -0,0 +1,5 @@ +package works.iterative.core.auth + +case class AccessToken(token: String) + +case class AuthedUserInfo(token: AccessToken, profile: BasicProfile) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala new file mode 100644 index 0000000..1dff6e1 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/Authentication.scala @@ -0,0 +1,15 @@ +package works.iterative.core.auth +package service + +import zio.* + +object Authentication extends AuthenticationService: + private val currentUser: FiberRef[Option[AuthedUserInfo]] = + Unsafe.unsafely( + FiberRef.unsafe.make(None) + ) + + override val currentUserInfo: UIO[Option[AuthedUserInfo]] = currentUser.get + + override def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] = + currentUser.set(Some(AuthedUserInfo(token, profile))) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala new file mode 100644 index 0000000..d714f98 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/service/AuthenticationService.scala @@ -0,0 +1,30 @@ +package works.iterative.core.auth +package service + +import zio.* +import works.iterative.core.UserMessage +import works.iterative.core.HasUserMessage + +sealed abstract class AuthenticationError(val userMessage: UserMessage) + extends RuntimeException(s"Authentication error: ${userMessage}") + with HasUserMessage + +object AuthenticationError: + case object NotLoggedIn + extends AuthenticationError(UserMessage("error.not.logged.in")) + +trait AuthenticationService: + def loggedIn(token: AccessToken, profile: BasicProfile): UIO[Unit] + def currentUserInfo: UIO[Option[AuthedUserInfo]] + + def currentAccessToken: UIO[Option[AccessToken]] = + currentUserInfo.map(_.map(_.token)) + + def provideCurrentUser[R, E, A]( + effect: ZIO[R & CurrentUser, E, A] + ): ZIO[R, E | AuthenticationError, A] = + currentUserInfo.flatMap { + case Some(info) => + effect.provideSome[R](ZLayer.succeed(CurrentUser(info.profile))) + case None => ZIO.fail(AuthenticationError.NotLoggedIn) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala new file mode 100644 index 0000000..883bd7a --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/DigestService.scala @@ -0,0 +1,26 @@ +package works.iterative.core.service + +import works.iterative.core.Digest +import works.iterative.core.DigestAlgorithm + +import zio.* +import zio.stream.* + +trait DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): IO[E, Digest] + +object DigestService: + def digest[E]( + algorithm: DigestAlgorithm, + content: Stream[E, Byte] + ): ZIO[DigestService, E, Digest] = + ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) + + def digest( + algorithm: DigestAlgorithm, + content: Array[Byte] + ): URIO[DigestService, Digest] = + digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala new file mode 100644 index 0000000..10bdeb7 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/FileStore.scala @@ -0,0 +1,29 @@ +package works.iterative.core.service + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef + +import zio.* + +trait FileStore: + type Op[A] = UIO[A] + def store(file: FileRepr): Op[FileRef] + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] + def load(url: String): Op[Option[Array[Byte]]] + +object FileStore: + type Op[A] = URIO[FileStore, A] + def store(file: FileRepr): Op[FileRef] = + ZIO.serviceWithZIO(_.store(file)) + def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.serviceWithZIO(_.store(name, file, contentType)) + def load(url: String): Op[Option[Array[Byte]]] = + ZIO.serviceWithZIO(_.load(url)) diff --git a/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala new file mode 100644 index 0000000..1779da3 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala @@ -0,0 +1,10 @@ +package works.iterative.core.service + +import zio.* + +/** Generator of unique IDs of a given type */ +trait IdGenerator[A]: + self => + def nextId: UIO[A] + def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: + def nextId: UIO[B] = self.nextId.map(f) diff --git a/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala new file mode 100644 index 0000000..23d1908 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala @@ -0,0 +1,47 @@ +package works.iterative.core.service + +import zio.* +import zio.json.* + +trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def get(key: Key): Eff[Option[Value]] + +trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: + def put(key: Key, value: Value): Eff[Unit] + def remove(key: Key): Eff[Unit] + +trait GenericKeyValueStore[Eff[+_], -Key, Value] + extends GenericReadKeyValueStore[Eff, Key, Value] + with GenericWriteKeyValueStore[Eff, Key, Value] + +trait ReadKeyValueStore[-Key, +Value] + extends GenericReadKeyValueStore[UIO, Key, Value] + +trait WriteKeyValueStore[-Key, -Value] + extends GenericWriteKeyValueStore[UIO, Key, Value] + +trait KeyValueStore[-Key, Value] + extends GenericKeyValueStore[UIO, Key, Value] + with ReadKeyValueStore[Key, Value] + with WriteKeyValueStore[Key, Value] + +type StringKeyValueStore = KeyValueStore[String, String] + +object KeyValueStore: + extension (store: StringKeyValueStore) + /** Decode the value, ignoring decoding errors if any */ + def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).map(_.flatMap(_.fromJson[A].toOption)) + + def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = + store.get(key).flatMap { + case None => ZIO.none + case Some(v) => + ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => + ZIO.log(err) *> ZIO.none + } + } + + def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = + store.put(key, value.toJson) diff --git a/core/shared/src/main/scala/works/iterative/core/service/Repository.scala b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala new file mode 100644 index 0000000..fa2b41d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/Repository.scala @@ -0,0 +1,52 @@ +package works.iterative.core.service + +import zio.* +import zio.stream.* + +trait GenericLoadService[Eff[+_], -Key, +Value]: + type Op[A] = Eff[A] + def load(id: Key): Op[Option[Value]] + +trait GenericUpdateNotifyService[Str[+_], Key]: + def updates: Str[Key] + +trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: + type Op[A] = Eff[A] + def loadAll(ids: Seq[Key]): Op[Coll[Value]] + +trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: + type Op[A] = Eff[A] + def find(filter: FilterArg): Op[Coll[Value]] + +trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] + extends GenericLoadService[Eff, Key, Value] + with GenericLoadAllService[Eff, Coll, Key, Value] + with GenericFindService[Eff, Coll, Key, Value, FilterArg] + +trait GenericWriteRepository[Eff[_], -Key, -Value]: + type Op[A] = Eff[A] + def save(key: Key, value: Value): Op[Unit] + +trait GenericRepository[Eff[+_], -Key, Value] + extends GenericReadRepository[Eff, List, Key, Value, Unit] + with GenericWriteRepository[Eff, Key, Value] + +type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] +type LoadAllRepository[-Key, +Value] = + GenericLoadAllService[UIO, List, Key, Value] + +trait ReadRepository[-Key, +Value, -FilterArg] + extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: + override def loadAll(ids: Seq[Key]): UIO[List[Value]] = + // Inefficient implementation, meant to be overridden + ZIO.foreach(ids)(load).map(_.flatten.toList) + +trait UpdateNotifyRepository[Key] + extends GenericUpdateNotifyService[UStream, Key] + +trait WriteRepository[-Key, -Value] + extends GenericWriteRepository[UIO, Key, Value] + +trait Repository[-Key, Value, -FilterArg] + extends ReadRepository[Key, Value, FilterArg] + with WriteRepository[Key, Value] diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala new file mode 100644 index 0000000..66ad66b --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala @@ -0,0 +1,21 @@ +package works.iterative.core.service +package impl + +import works.iterative.core.FileSupport.* +import works.iterative.core.FileRef +import zio.* + +object InMemoryFileStore: + val layer: ULayer[FileStore] = ZLayer.succeed { + new FileStore: + override def store(file: FileRepr): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(file.name, "#")) + override def store( + name: String, + file: Array[Byte], + contentType: Option[String] + ): Op[FileRef] = + ZIO.succeed(FileRef.unsafe(name, "#")) + override def load(url: String): Op[Option[Array[Byte]]] = + ZIO.succeed(None) + } diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala new file mode 100644 index 0000000..e3b7d45 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala @@ -0,0 +1,17 @@ +package works.iterative.core.service +package impl + +import zio.* + +trait InMemoryRepository[Key, Value, FilterArg]( + data: Ref[Map[Key, Value]], + filter: FilterArg => Value => Boolean +) extends Repository[Key, Value, FilterArg]: + override def save(key: Key, value: Value): UIO[Unit] = + data.update(_ + (key -> value)) + override def load(key: Key): UIO[Option[Value]] = + data.get.map(_.get(key)) + override def loadAll(keys: Seq[Key]): UIO[List[Value]] = + data.get.map(_.view.filterKeys(keys.contains).values.toList) + override def find(filterArg: FilterArg): UIO[List[Value]] = + data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala new file mode 100644 index 0000000..358cb9d --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala @@ -0,0 +1,11 @@ +package works.iterative.core.service.impl + +import works.iterative.core.service.IdGenerator +import zio.* + +class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: + def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) + +object UUIDGenerator: + def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = + ZLayer.succeed(UUIDGenerator(f)) diff --git a/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala new file mode 100644 index 0000000..0548a3f --- /dev/null +++ b/service-specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala @@ -0,0 +1,34 @@ +package works.iterative.core +package service +package specs + +import zio.* +import zio.test.* + +abstract class KeyValueStoreSpec extends ZIOSpecDefault: + val defaultSpec = suite("Consul spec")( + test("get returns None if key does not exist") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + value <- kv.get(key) + yield assertTrue(value.isEmpty) + }, + test("read/put/delete works") { + for + kv <- ZIO.service[StringKeyValueStore] + uuid <- Random.nextUUID + key = s"test/$uuid" + originalValue <- kv.get(key) + _ <- kv.put(key, "test_value") + updatedValue <- kv.get(key) + _ <- kv.remove(key) + removedValue <- kv.get(key) + yield assertTrue( + originalValue.isEmpty, + updatedValue.contains("test_value"), + removedValue.isEmpty + ) + } + ) diff --git a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala b/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala deleted file mode 100644 index 16211a8..0000000 --- a/service/js/src/main/scala/works/iterative/core/service/impl/JsStorageRepository.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service.impl - -import org.scalajs.dom.Storage -import works.iterative.core.service.Repository -import zio.* -import zio.json.* - -// TODO: improve error reporting on generic repositories -// This is good just for prototypes -// And it cannot be used to query data, as there is no way to iterate the storage -class JsStorageRepository[Value: JsonCodec]( - storage: Storage -) extends Repository[String, Value, String]: - - override def load(id: String): UIO[Option[Value]] = { - for - raw <- ZIO.attemptBlocking(Option(storage.getItem(id))) - data <- ZIO.foreach(raw) { r => - ZIO - .fromEither(r.fromJson[Value]) - .mapError(_ => new RuntimeException(s"Failed to parse: ${raw}")) - } - yield data - }.orDie - - override def save(key: String, value: Value): UIO[Unit] = - ZIO.attemptBlocking(storage.setItem(key, value.toJson)).orDie - - override def find(id: String): UIO[List[Value]] = load(id).map(_.toList) diff --git a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala b/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala deleted file mode 100644 index 8c88818..0000000 --- a/service/jvm/src/main/scala/works/iterative/core/service/impl/JcaDigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core -package service -package impl - -import zio.* -import zio.stream.* - -class JcaDigestService extends DigestService: - - override def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] = - for - md <- ZIO - .attempt( - java.security.MessageDigest.getInstance(algorithm.value) - ) - .orDie - _ <- content.runForeachChunk(e => ZIO.attempt(md.update(e.toArray)).orDie) - digest <- ZIO.attempt(md.digest()).orDie - yield Digest(algorithm, digest) - -object JcaDigestGenerator: - val layer: ULayer[DigestService] = - ZLayer.succeed(new JcaDigestService) diff --git a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala b/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala deleted file mode 100644 index 883bd7a..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/DigestService.scala +++ /dev/null @@ -1,26 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.Digest -import works.iterative.core.DigestAlgorithm - -import zio.* -import zio.stream.* - -trait DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): IO[E, Digest] - -object DigestService: - def digest[E]( - algorithm: DigestAlgorithm, - content: Stream[E, Byte] - ): ZIO[DigestService, E, Digest] = - ZIO.serviceWithZIO[DigestService](_.digest(algorithm, content)) - - def digest( - algorithm: DigestAlgorithm, - content: Array[Byte] - ): URIO[DigestService, Digest] = - digest(algorithm, ZStream.fromChunk(Chunk.fromArray(content))) diff --git a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala deleted file mode 100644 index 10bdeb7..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/FileStore.scala +++ /dev/null @@ -1,29 +0,0 @@ -package works.iterative.core.service - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef - -import zio.* - -trait FileStore: - type Op[A] = UIO[A] - def store(file: FileRepr): Op[FileRef] - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] - def load(url: String): Op[Option[Array[Byte]]] - -object FileStore: - type Op[A] = URIO[FileStore, A] - def store(file: FileRepr): Op[FileRef] = - ZIO.serviceWithZIO(_.store(file)) - def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.serviceWithZIO(_.store(name, file, contentType)) - def load(url: String): Op[Option[Array[Byte]]] = - ZIO.serviceWithZIO(_.load(url)) diff --git a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala deleted file mode 100644 index 1779da3..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/IdGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.core.service - -import zio.* - -/** Generator of unique IDs of a given type */ -trait IdGenerator[A]: - self => - def nextId: UIO[A] - def map[B](f: A => B): IdGenerator[B] = new IdGenerator[B]: - def nextId: UIO[B] = self.nextId.map(f) diff --git a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala b/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala deleted file mode 100644 index 23d1908..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/KeyValueStore.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.json.* - -trait GenericReadKeyValueStore[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def get(key: Key): Eff[Option[Value]] - -trait GenericWriteKeyValueStore[Eff[+_], -Key, -Value]: - def put(key: Key, value: Value): Eff[Unit] - def remove(key: Key): Eff[Unit] - -trait GenericKeyValueStore[Eff[+_], -Key, Value] - extends GenericReadKeyValueStore[Eff, Key, Value] - with GenericWriteKeyValueStore[Eff, Key, Value] - -trait ReadKeyValueStore[-Key, +Value] - extends GenericReadKeyValueStore[UIO, Key, Value] - -trait WriteKeyValueStore[-Key, -Value] - extends GenericWriteKeyValueStore[UIO, Key, Value] - -trait KeyValueStore[-Key, Value] - extends GenericKeyValueStore[UIO, Key, Value] - with ReadKeyValueStore[Key, Value] - with WriteKeyValueStore[Key, Value] - -type StringKeyValueStore = KeyValueStore[String, String] - -object KeyValueStore: - extension (store: StringKeyValueStore) - /** Decode the value, ignoring decoding errors if any */ - def getAsMaybe[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).map(_.flatMap(_.fromJson[A].toOption)) - - def getAsMaybeLogged[A: JsonDecoder](key: String): UIO[Option[A]] = - store.get(key).flatMap { - case None => ZIO.none - case Some(v) => - ZIO.fromEither(v.fromJson[A]).asSome.catchAll { err => - ZIO.log(err) *> ZIO.none - } - } - - def putAsJson[A: JsonEncoder](key: String, value: A): UIO[Unit] = - store.put(key, value.toJson) diff --git a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala b/service/shared/src/main/scala/works/iterative/core/service/Repository.scala deleted file mode 100644 index fa2b41d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/Repository.scala +++ /dev/null @@ -1,52 +0,0 @@ -package works.iterative.core.service - -import zio.* -import zio.stream.* - -trait GenericLoadService[Eff[+_], -Key, +Value]: - type Op[A] = Eff[A] - def load(id: Key): Op[Option[Value]] - -trait GenericUpdateNotifyService[Str[+_], Key]: - def updates: Str[Key] - -trait GenericLoadAllService[Eff[+_], Coll[+_], -Key, +Value]: - type Op[A] = Eff[A] - def loadAll(ids: Seq[Key]): Op[Coll[Value]] - -trait GenericFindService[Eff[+_], Coll[+_], -Key, +Value, -FilterArg]: - type Op[A] = Eff[A] - def find(filter: FilterArg): Op[Coll[Value]] - -trait GenericReadRepository[Eff[+_], Coll[+_], -Key, +Value, -FilterArg] - extends GenericLoadService[Eff, Key, Value] - with GenericLoadAllService[Eff, Coll, Key, Value] - with GenericFindService[Eff, Coll, Key, Value, FilterArg] - -trait GenericWriteRepository[Eff[_], -Key, -Value]: - type Op[A] = Eff[A] - def save(key: Key, value: Value): Op[Unit] - -trait GenericRepository[Eff[+_], -Key, Value] - extends GenericReadRepository[Eff, List, Key, Value, Unit] - with GenericWriteRepository[Eff, Key, Value] - -type LoadRepository[-Key, +Value] = GenericLoadService[UIO, Key, Value] -type LoadAllRepository[-Key, +Value] = - GenericLoadAllService[UIO, List, Key, Value] - -trait ReadRepository[-Key, +Value, -FilterArg] - extends GenericReadRepository[UIO, List, Key, Value, FilterArg]: - override def loadAll(ids: Seq[Key]): UIO[List[Value]] = - // Inefficient implementation, meant to be overridden - ZIO.foreach(ids)(load).map(_.flatten.toList) - -trait UpdateNotifyRepository[Key] - extends GenericUpdateNotifyService[UStream, Key] - -trait WriteRepository[-Key, -Value] - extends GenericWriteRepository[UIO, Key, Value] - -trait Repository[-Key, Value, -FilterArg] - extends ReadRepository[Key, Value, FilterArg] - with WriteRepository[Key, Value] diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala deleted file mode 100644 index 66ad66b..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryFileStore.scala +++ /dev/null @@ -1,21 +0,0 @@ -package works.iterative.core.service -package impl - -import works.iterative.core.FileSupport.* -import works.iterative.core.FileRef -import zio.* - -object InMemoryFileStore: - val layer: ULayer[FileStore] = ZLayer.succeed { - new FileStore: - override def store(file: FileRepr): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(file.name, "#")) - override def store( - name: String, - file: Array[Byte], - contentType: Option[String] - ): Op[FileRef] = - ZIO.succeed(FileRef.unsafe(name, "#")) - override def load(url: String): Op[Option[Array[Byte]]] = - ZIO.succeed(None) - } diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala deleted file mode 100644 index e3b7d45..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/InMemoryRepository.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.core.service -package impl - -import zio.* - -trait InMemoryRepository[Key, Value, FilterArg]( - data: Ref[Map[Key, Value]], - filter: FilterArg => Value => Boolean -) extends Repository[Key, Value, FilterArg]: - override def save(key: Key, value: Value): UIO[Unit] = - data.update(_ + (key -> value)) - override def load(key: Key): UIO[Option[Value]] = - data.get.map(_.get(key)) - override def loadAll(keys: Seq[Key]): UIO[List[Value]] = - data.get.map(_.view.filterKeys(keys.contains).values.toList) - override def find(filterArg: FilterArg): UIO[List[Value]] = - data.get.map(_.values.filter(filter(filterArg)).toList) diff --git a/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala b/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala deleted file mode 100644 index 358cb9d..0000000 --- a/service/shared/src/main/scala/works/iterative/core/service/impl/UUIDGenerator.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.core.service.impl - -import works.iterative.core.service.IdGenerator -import zio.* - -class UUIDGenerator[A](f: String => A) extends IdGenerator[A]: - def nextId: UIO[A] = Random.nextUUID.map(v => f(v.toString)) - -object UUIDGenerator: - def layer[A: Tag](f: String => A): ULayer[IdGenerator[A]] = - ZLayer.succeed(UUIDGenerator(f)) diff --git a/service/specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala b/service/specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala deleted file mode 100644 index 0548a3f..0000000 --- a/service/specs/shared/src/main/scala/works/iterative/core/service/specs/KeyValueStoreSpec.scala +++ /dev/null @@ -1,34 +0,0 @@ -package works.iterative.core -package service -package specs - -import zio.* -import zio.test.* - -abstract class KeyValueStoreSpec extends ZIOSpecDefault: - val defaultSpec = suite("Consul spec")( - test("get returns None if key does not exist") { - for - kv <- ZIO.service[StringKeyValueStore] - uuid <- Random.nextUUID - key = s"test/$uuid" - value <- kv.get(key) - yield assertTrue(value.isEmpty) - }, - test("read/put/delete works") { - for - kv <- ZIO.service[StringKeyValueStore] - uuid <- Random.nextUUID - key = s"test/$uuid" - originalValue <- kv.get(key) - _ <- kv.put(key, "test_value") - updatedValue <- kv.get(key) - _ <- kv.remove(key) - removedValue <- kv.get(key) - yield assertTrue( - originalValue.isEmpty, - updatedValue.contains("test_value"), - removedValue.isEmpty - ) - } - ) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala new file mode 100644 index 0000000..e1a7690 --- /dev/null +++ b/tapir/shared/src/main/scala/works/iterative/tapir/ApiClientFactory.scala @@ -0,0 +1,61 @@ +package works.iterative.tapir + +import zio.* +import sttp.tapir.Endpoint +import sttp.capabilities.WebSockets +import sttp.capabilities.zio.ZioStreams +import works.iterative.core.auth.AccessToken +import works.iterative.core.auth.service.* + +trait ApiClientFactory: + // TODO: Handle all authentication errors here, make sure that we remove them from the type system + // Authentication errors do not seem to be defects. + def make[I, E, O]( + endpoint: Endpoint[ + AccessToken, + I, + E, + O, + ZioStreams & WebSockets + ] + )(using + b: BaseUriExtractor[O], + e: ClientErrorConstructor[E] + ): I => IO[e.Error, O] + +class AuthenticatedApiClientFactory( + authentication: AuthenticationService, + clientFactory: ClientEndpointFactory +) extends ApiClientFactory: + def make[I, E, O]( + endpoint: Endpoint[ + AccessToken, + I, + E, + O, + ZioStreams & WebSockets + ] + )(using + b: BaseUriExtractor[O], + e: ClientErrorConstructor[E] + ): I => IO[e.Error, O] = + val client: AccessToken => I => IO[e.Error, O] = clientFactory.make( + endpoint + )(using b, e, ClientResultConstructor.secureResultConstructor) + input => + authentication.currentAccessToken.flatMap { + case Some(token) => client(token)(input) + case None => ZIO.die(AuthenticationError.NotLoggedIn) + } + +object ApiClientFactory: + val layer: URLayer[ClientEndpointFactory, ApiClientFactory] = + ZLayer { + for factory <- ZIO.service[ClientEndpointFactory] + yield AuthenticatedApiClientFactory(Authentication, factory) + } + + def withAuthentication: URLayer[ + ClientEndpointFactory & AuthenticationService, + ApiClientFactory + ] = ZLayer.derive[AuthenticatedApiClientFactory]